Created
June 12, 2025 14:44
-
-
Save sajjadyousefnia/4bd2fc4d69a7137f0f6bf1faa69bf0db to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\data\navigation\Navigation.kt | |
```kt | |
package com.divadventure.data.navigation | |
import android.content.SharedPreferences | |
import androidx.compose.animation.core.FastOutSlowInEasing | |
import androidx.compose.animation.core.tween | |
import androidx.compose.animation.fadeIn | |
import androidx.compose.animation.fadeOut | |
import androidx.compose.animation.slideInHorizontally | |
import androidx.compose.animation.slideOutHorizontally | |
import androidx.compose.foundation.layout.PaddingValues | |
import androidx.compose.runtime.Composable | |
import androidx.compose.runtime.LaunchedEffect | |
import androidx.compose.runtime.getValue | |
import androidx.compose.runtime.remember | |
import androidx.hilt.navigation.compose.hiltViewModel | |
import androidx.lifecycle.compose.LocalLifecycleOwner | |
import androidx.lifecycle.compose.collectAsStateWithLifecycle | |
import androidx.navigation.NavHostController | |
import androidx.navigation.NavType | |
import androidx.navigation.compose.NavHost | |
import androidx.navigation.compose.composable | |
import androidx.navigation.compose.rememberNavController | |
import androidx.navigation.navArgument | |
import com.divadventure.App | |
import com.divadventure.di.SharedPreferencesModule | |
import com.divadventure.di.SharedPrefs | |
import com.divadventure.domain.models.Adventure | |
import com.divadventure.ui.screens.ChangeEmail | |
import com.divadventure.ui.screens.ForgotPasswordScreen | |
import com.divadventure.ui.screens.LandingScreen | |
import com.divadventure.ui.screens.LoginScreen | |
import com.divadventure.ui.screens.OnboardingScreen | |
import com.divadventure.ui.screens.ResetPassword | |
import com.divadventure.ui.screens.SignUpScreen | |
import com.divadventure.ui.screens.SplashScreen | |
import com.divadventure.ui.screens.VerificationScreen | |
import com.divadventure.ui.screens.main.add.AddOrEditAdventure | |
import com.divadventure.ui.screens.main.add.AdventureInvitationRequests | |
import com.divadventure.ui.screens.main.add.AdventureJoinRequests | |
import com.divadventure.ui.screens.main.add.AdventurePreview | |
import com.divadventure.ui.screens.main.add.manage.ManageAdventure | |
import com.divadventure.ui.screens.main.add.OwnerParticipantMenu | |
import com.divadventure.ui.screens.main.home.MainScreen | |
import com.divadventure.ui.screens.main.home.notifications.Notifications | |
import com.divadventure.ui.screens.main.home.notifications.search.filter.AdventureInterests | |
import com.divadventure.ui.screens.main.home.notifications.search.filter.Filter | |
import com.divadventure.ui.screens.main.home.notifications.search.filter.FilterInterests | |
import com.divadventure.ui.screens.main.home.notifications.search.filter.Location | |
import com.divadventure.ui.screens.main.home.notifications.search.filter.Status | |
import com.divadventure.ui.screens.main.home.notifications.search.sortby.SortBy | |
import com.divadventure.ui.screens.main.profile.AccountSettings | |
import com.divadventure.ui.screens.main.profile.ElseProfile | |
import com.divadventure.ui.screens.main.profile.NotificationsSettings | |
import com.divadventure.ui.screens.main.profile.PrivacySettings | |
import com.divadventure.viewmodel.AuthViewModel | |
import com.divadventure.viewmodel.HomeViewModel | |
import com.divadventure.viewmodel.MainViewModel | |
import com.divadventure.viewmodel.ManageAdventureViewModel | |
import com.divadventure.viewmodel.NotificationsViewModel | |
import com.divadventure.viewmodel.ProfileViewModel | |
import com.google.gson.Gson | |
import kotlinx.serialization.Serializable | |
import java.net.URLDecoder | |
import java.net.URLEncoder | |
/** | |
* Utility function to decode an Adventure object from a URL-encoded string | |
*/ | |
fun decodeAdventureFromString(encodedString: String?): Adventure? { | |
return encodedString?.let { encoded -> | |
try { | |
// Decode the URL-encoded string first | |
val decodedJson = URLDecoder.decode(encoded, "UTF-8") | |
Gson().fromJson(decodedJson, Adventure::class.java) | |
} catch (e: Exception) { | |
e.printStackTrace() | |
null | |
} | |
} | |
} | |
@Serializable | |
sealed class Screen(val route: String) { | |
object Splash : Screen("splash_screen") | |
@Serializable | |
object Landing : Screen("landing_screen") | |
object Login : Screen("login_screen") | |
object SignUp : Screen("signup_screen") | |
object Onboarding : Screen("onboarding_screen") | |
object Verification : Screen("verification_screen") | |
object VerificationChangeEmail : Screen("verification_change_email_screen") | |
object ResetPassword : Screen("reset_password_screen") | |
object ForgotPassword : Screen("forgot_password_screen") | |
object Main : Screen("main_screen") | |
object Notifications : Screen("notifications_screen") | |
object SortBy : Screen("sort_by_screen") | |
object Filter : Screen("filter_screen") | |
object Interests : Screen("interests_screen") | |
object Status : Screen("status_screen") | |
object AdventureInterests : Screen("adventure_interests") | |
object AdventurePreview : Screen("adventure_preview") | |
object Location : Screen("location_screen") | |
object ElseProfile : Screen("else_profile/{profileId}") { | |
fun createRoute(profileId: String): String = "else_profile/$profileId" | |
} | |
object ManageAdventure : Screen("manage_adventure/{adventure}") { | |
fun createRoute(adventure: String): String = "manage_adventure/$adventure" | |
} | |
object AccountSettings : Screen("account_settings") | |
object NotificationsSettings : Screen("notifications_settings") | |
object PrivacySettings : Screen("privacy_settings") | |
object AddOrEditAdventure : Screen("add_or_edit_adventure") | |
object AdventureOwnerParticipantMenu : Screen("adventure_owner_participant_menu/{adventure}") { | |
fun createRoute(adventure: String): String = "adventure_owner_participant_menu/$adventure" | |
} | |
object AdventureJoinRequests : Screen("adventure_join_requests/{adventure}") { | |
fun createRoute(adventure: String): String = "adventure_join_requests/$adventure" | |
} | |
object AdventureInvitationRequests : Screen("adventure_invitation_requests/{adventure}") { | |
fun createRoute(adventure: String): String = "adventure_invitation_requests/$adventure" | |
} | |
object AdventureParticipantManagement : Screen("adventure_participant_management/{adventure}") { | |
fun createRoute(adventure: String): String = "adventure_participant_management/$adventure" | |
} | |
} | |
@Composable | |
fun MyNavHost( | |
padding: PaddingValues, | |
navController: NavHostController = rememberNavController(), | |
navigationViewModel: NavigationViewModel = hiltViewModel(), | |
sharedPrefs: SharedPreferences = SharedPreferencesModule.provideSharedPreferences(App.Companion.getContext()!!) | |
) { | |
// Keep track of "live" routes in the back stack | |
val liveRoutes = remember { mutableListOf<String>() } | |
val customNavigationStackManager = rememberCustomNavigationStackManager(navController) | |
navigationViewModel.setNavigationManager(customNavigationStackManager) | |
val lifecycleOwner = LocalLifecycleOwner.current | |
val navigationEvent by navigationViewModel.navigationEvent.collectAsStateWithLifecycle( | |
lifecycleOwner | |
) | |
val authViewModel = hiltViewModel<AuthViewModel>() | |
// val loginViewModel = hiltViewModel<LoginViewModel>() | |
// val signupViewModel = hiltViewModel<SignupViewModel>() | |
val mainViewModel = hiltViewModel<MainViewModel>() | |
val notificationsViewModel = hiltViewModel<NotificationsViewModel>() | |
val homeViewModel = hiltViewModel<HomeViewModel>() | |
val adventureViewModel = hiltViewModel<ManageAdventureViewModel>() | |
LaunchedEffect(key1 = Unit) { | |
navigationViewModel.navigationEvent.collect { event -> | |
when (event) { | |
is NavigationEvent.NavigateTo -> { | |
navController.addOnDestinationChangedListener(event.onDestinationChangedListener) | |
navController.navigate(event.screen.route) { | |
launchSingleTop = true | |
if (event.popUpTo != null) { | |
popUpTo(event.popUpTo.route) { | |
inclusive = event.inclusive | |
} | |
} | |
} | |
if (event.removeListenerAfter) { | |
navController.removeOnDestinationChangedListener(event.onDestinationChangedListener) | |
} | |
} | |
is NavigationEvent.NavigateBack -> { | |
if (event.popUpToRoute != null) { | |
navController.popBackStack(event.popUpToRoute, event.inclusive) | |
} else { | |
navController.popBackStack() | |
} | |
} | |
is NavigationEvent.PopSpecific -> { | |
navController.popBackStack(event.route.route, event.inclusive) | |
} | |
is NavigationEvent.PopSpecificList -> { | |
event.routes.forEach { route -> | |
navController.popBackStack(route, event.inclusive) | |
} | |
} | |
NavigationEvent.PopBackStack -> { | |
navController.popBackStack() | |
} | |
NavigationEvent.RemoveAllPriorRoutes -> { | |
while (navController.popBackStack()) { | |
// Clear entire back stack | |
} | |
} | |
is NavigationEvent.NavigateProfile -> { | |
navController.addOnDestinationChangedListener(event.onDestinationChangedListener) | |
navController.navigate(Screen.ElseProfile.createRoute(event.profileId)) { | |
launchSingleTop = false | |
} | |
if (event.removeListenerAfter) { | |
navController.removeOnDestinationChangedListener(event.onDestinationChangedListener) | |
} | |
} | |
is NavigationEvent.NavigateAdventure -> { | |
try { | |
navController.addOnDestinationChangedListener(event.onDestinationChangedListener) | |
// Ensure the adventure object has initialized lists to prevent serialization issues | |
val safeAdventure = event.adventure.copy( | |
adventureRequest = event.adventure.adventureRequest ?: emptyList(), | |
adventurers = event.adventure.adventurers.ifEmpty { emptyList() } | |
) | |
// Use URI encoding to safely pass complex JSON as a navigation parameter | |
val adventureJson = Gson().toJson(safeAdventure) | |
val encodedAdventure = URLEncoder.encode(adventureJson, "UTF-8") | |
navController.navigate(Screen.ManageAdventure.createRoute(encodedAdventure)) | |
if (event.removeListenerAfter) { | |
navController.removeOnDestinationChangedListener(event.onDestinationChangedListener) | |
} | |
} catch (e: Exception) { | |
// Log the error but prevent app crash | |
e.printStackTrace() | |
} | |
} | |
is NavigationEvent.NavigateAdventureOwnerParticipantMenu -> { | |
try { | |
navController.addOnDestinationChangedListener(event.onDestinationChangedListener) | |
// Ensure the adventure object has initialized lists to prevent serialization issues | |
val safeAdventure = event.adventure.copy( | |
adventureRequest = event.adventure.adventureRequest ?: emptyList(), | |
adventurers = event.adventure.adventurers.ifEmpty { emptyList() } | |
) | |
// Use URI encoding to safely pass complex JSON as a navigation parameter | |
val adventureJson = Gson().toJson(safeAdventure) | |
val encodedAdventure = URLEncoder.encode(adventureJson, "UTF-8") | |
navController.navigate( | |
Screen.AdventureOwnerParticipantMenu.createRoute( | |
encodedAdventure | |
) | |
) { | |
launchSingleTop = true | |
} | |
if (event.removeListenerAfter) { | |
navController.removeOnDestinationChangedListener(event.onDestinationChangedListener) | |
} | |
} catch (e: Exception) { | |
// Log the error but prevent app crash | |
e.printStackTrace() | |
} | |
} | |
is NavigationEvent.NavigateAdventureOptions -> { | |
try { | |
// Register destination change listener | |
navController.addOnDestinationChangedListener(event.onDestinationChangedListener) | |
// Ensure the adventure object has initialized lists to prevent serialization issues | |
val safeAdventure = event.adventure.copy( | |
adventureRequest = event.adventure.adventureRequest ?: emptyList(), | |
adventurers = event.adventure.adventurers.ifEmpty { emptyList() } | |
) | |
// Use URI encoding to safely pass complex JSON as a navigation parameter | |
val adventureJson = Gson().toJson(safeAdventure) | |
val encodedAdventure = URLEncoder.encode(adventureJson, "UTF-8") | |
// Navigate to the appropriate route based on the screen type | |
when (event.partName) { | |
is Screen.AdventureJoinRequests -> { | |
navController.navigate( | |
Screen.AdventureJoinRequests.createRoute(encodedAdventure) | |
) { launchSingleTop = true } | |
} | |
is Screen.AdventureInvitationRequests -> { | |
navController.navigate( | |
Screen.AdventureInvitationRequests.createRoute(encodedAdventure) | |
) { launchSingleTop = true } | |
} | |
is Screen.AdventureParticipantManagement -> { | |
navController.navigate( | |
Screen.AdventureParticipantManagement.createRoute( | |
encodedAdventure | |
) | |
) { launchSingleTop = true } | |
} | |
else -> { | |
throw IllegalArgumentException("Unknown screen type") | |
} | |
} | |
// Remove listener if needed | |
if (event.removeListenerAfter) { | |
navController.removeOnDestinationChangedListener(event.onDestinationChangedListener) | |
} | |
} catch (e: Exception) { | |
e.printStackTrace() | |
// Optional: Handle navigation error | |
} | |
} | |
} | |
} | |
} | |
NavHost( | |
navController = navController, | |
startDestination = Screen.Splash.route, | |
enterTransition = { | |
slideInHorizontally( | |
initialOffsetX = { it / 2 }, animationSpec = tween( | |
500, easing = { FastOutSlowInEasing.transform(it) }) | |
) + fadeIn(animationSpec = tween(500)) | |
}, | |
exitTransition = { | |
slideOutHorizontally( | |
targetOffsetX = { -it / 2 }, animationSpec = tween( | |
500, easing = { FastOutSlowInEasing.transform(it) }) | |
) + fadeOut(animationSpec = tween(500)) | |
}, | |
popEnterTransition = { | |
slideInHorizontally( | |
initialOffsetX = { -it / 2 }, animationSpec = tween( | |
500, easing = { FastOutSlowInEasing.transform(it) }) | |
) + fadeIn(animationSpec = tween(500)) | |
}, | |
popExitTransition = { | |
slideOutHorizontally( | |
targetOffsetX = { it / 2 }, animationSpec = tween( | |
500, easing = { FastOutSlowInEasing.transform(it) }) | |
) + fadeOut(animationSpec = tween(500)) | |
}, | |
) { | |
composable(Screen.Splash.route) { | |
SplashScreen( | |
navigationViewModel = navigationViewModel, viewModel = authViewModel | |
) | |
} | |
composable(Screen.Landing.route) { | |
LandingScreen( | |
navigationViewModel = navigationViewModel, | |
viewModel = authViewModel, | |
padding = padding | |
) | |
} | |
composable(Screen.Main.route) { | |
MainScreen( | |
homeViewModel, | |
mainViewModel, | |
adventureViewModel, | |
navigationViewModel = navigationViewModel, | |
padding = padding, | |
) | |
} | |
composable(Screen.Login.route) { | |
LoginScreen( | |
navigationViewModel = navigationViewModel, | |
viewModel = authViewModel, | |
) | |
} | |
composable(Screen.SignUp.route) { | |
SignUpScreen( | |
navigationViewModel = navigationViewModel, | |
viewModel = authViewModel, | |
padding = padding | |
) | |
} | |
composable(Screen.Onboarding.route) { | |
OnboardingScreen( | |
navigationViewModel = navigationViewModel, viewModel = authViewModel, padding | |
) | |
} | |
composable(Screen.Verification.route) { | |
VerificationScreen( | |
navigationViewModel = navigationViewModel, | |
viewModel = authViewModel, | |
padding = padding, | |
sharedPrefs = SharedPrefs(sharedPrefs) | |
) | |
} | |
composable(Screen.VerificationChangeEmail.route) { | |
ChangeEmail( | |
navigationViewModel = navigationViewModel, | |
viewModel = authViewModel, | |
padding = padding | |
) | |
} | |
composable(Screen.ResetPassword.route) { | |
ResetPassword( | |
navigationViewModel = navigationViewModel, | |
viewModel = authViewModel, | |
padding = padding | |
) | |
} | |
composable(Screen.ForgotPassword.route) { | |
ForgotPasswordScreen( | |
navigationViewModel = navigationViewModel, viewModel = authViewModel | |
) | |
} | |
composable(Screen.Notifications.route) { | |
Notifications( | |
padding = padding, | |
notificationsViewModel = notificationsViewModel, | |
navigationViewModel = navigationViewModel, | |
) | |
} | |
composable(Screen.SortBy.route) { | |
SortBy( | |
mainViewModel, | |
navigationViewModel, | |
homeViewModel, | |
padding = padding, | |
) | |
} | |
composable(Screen.Filter.route) { | |
Filter( | |
mainViewModel = mainViewModel, | |
navigationViewModel = navigationViewModel, | |
homeViewModel = homeViewModel, | |
paddingValues = padding | |
) | |
} | |
composable(Screen.Interests.route) { | |
FilterInterests( | |
navigationViewModel = navigationViewModel, homeViewModel, | |
paddingValues = padding | |
) | |
} | |
composable(Screen.AdventureInterests.route) { | |
AdventureInterests( | |
navigationViewModel = navigationViewModel, | |
adventureViewModel = adventureViewModel, | |
paddingValues = padding | |
) | |
} | |
composable(Screen.AdventurePreview.route) { | |
AdventurePreview( | |
adventureViewModel, | |
mainViewModel, | |
navigationViewModel, | |
padding | |
) | |
} | |
composable(Screen.Status.route) { | |
Status( | |
mainViewModel = mainViewModel, | |
navigationViewModel = navigationViewModel, | |
homeViewModel = homeViewModel, | |
paddingValues = padding | |
) | |
} | |
composable(Screen.Location.route) { | |
Location( | |
mainViewModel = mainViewModel, | |
navigationViewModel = navigationViewModel, | |
homeViewModel = homeViewModel, | |
paddingValues = padding | |
) | |
} | |
composable( | |
route = Screen.ElseProfile.route, | |
arguments = listOf(navArgument("profileId") { type = NavType.StringType }) | |
) { backStackEntry -> | |
val profileId = backStackEntry.arguments?.getString("profileId") // Extract the argument | |
ElseProfile( | |
profileId = profileId ?: "", // Pass the argument to the composable | |
paddings = padding, | |
mainViewModel = mainViewModel, | |
profileViewModel = hiltViewModel<ProfileViewModel>(), | |
) | |
} | |
composable(Screen.AccountSettings.route) { | |
AccountSettings(padding) | |
} | |
composable(Screen.NotificationsSettings.route) { | |
NotificationsSettings(padding) | |
} | |
composable(Screen.PrivacySettings.route) { | |
PrivacySettings(padding) | |
} | |
composable( | |
Screen.ManageAdventure.route, | |
arguments = listOf(navArgument("adventure") { type = NavType.StringType }) | |
) { | |
val encodedAdventureString = it.arguments?.getString("adventure") | |
val adventure = decodeAdventureFromString(encodedAdventureString) | |
ManageAdventure( | |
padding, | |
navigationViewModel, | |
mainViewModel, | |
adventureViewModel, | |
adventure!! | |
) | |
} | |
composable( | |
Screen.AddOrEditAdventure.route | |
) { | |
AddOrEditAdventure( | |
padding, mainViewModel, adventureViewModel, navigationViewModel | |
) | |
} | |
composable( | |
Screen.AdventureOwnerParticipantMenu.route, | |
arguments = listOf(navArgument("adventure") { type = NavType.StringType }) | |
) { | |
val encodedAdventureString = it.arguments?.getString("adventure") | |
val adventure = decodeAdventureFromString(encodedAdventureString) | |
OwnerParticipantMenu( | |
paddingValues = padding, | |
adventure = adventure!!, | |
mainViewModel = mainViewModel, | |
navigationViewModel = navigationViewModel, | |
manageAdventureViewModel = adventureViewModel | |
) | |
} | |
composable( | |
Screen.AdventureInvitationRequests.route | |
) { | |
val encodedAdventureString = it.arguments?.getString("adventure") | |
val adventure = decodeAdventureFromString(encodedAdventureString) | |
AdventureInvitationRequests(padding, adventure!!) | |
} | |
composable( | |
Screen.AdventureJoinRequests.route, | |
arguments = listOf(navArgument("adventure") { type = NavType.StringType }) // Ensure arguments are correctly defined | |
) { backStackEntry -> // It's good practice to name this 'backStackEntry' | |
val encodedAdventureString = backStackEntry.arguments?.getString("adventure") | |
val adventure = decodeAdventureFromString(encodedAdventureString) | |
// Pass the shared adventureViewModel instance | |
AdventureJoinRequests( | |
padding = padding, // Corrected: use 'padding' to match the signature | |
adventure = adventure!!, | |
manageAdventureViewModel = adventureViewModel // Pass the shared instance | |
) | |
} | |
/** todo : add participant management | |
composable( | |
Screen.AdventureParticipantManagement.route | |
) { | |
val encodedAdventureString = it.arguments?.getString("adventure") | |
val adventure = decodeAdventureFromString(encodedAdventureString) | |
AdventureParticipantManagement(padding, adventure!!) | |
} | |
*/ | |
} | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\data\navigation\NavigationEvent.kt | |
```kt | |
package com.divadventure.data.navigation | |
import androidx.navigation.NavController | |
import com.divadventure.domain.models.Adventure | |
sealed class NavigationEvent { | |
data class NavigateTo( | |
val screen: Screen, | |
val popUpTo: Screen? = null, | |
val inclusive: Boolean = false, | |
val onDestinationChangedListener: NavController.OnDestinationChangedListener, | |
val singleTop: Boolean = true, | |
val removeListenerAfter: Boolean = true | |
) : NavigationEvent() | |
object RemoveAllPriorRoutes : NavigationEvent() | |
data class NavigateBack( | |
val popUpToRoute: String? = null, | |
val inclusive: Boolean = false | |
) : NavigationEvent() | |
data class PopSpecific( | |
val route: Screen, | |
val inclusive: Boolean, | |
) : NavigationEvent() | |
data class NavigateProfile( | |
val profileId: String, | |
val onDestinationChangedListener: NavController.OnDestinationChangedListener, | |
val removeListenerAfter: Boolean = true | |
) : NavigationEvent() | |
object PopBackStack : NavigationEvent() | |
data class NavigateAdventure( | |
val adventure: Adventure, | |
val onDestinationChangedListener: NavController.OnDestinationChangedListener, | |
val removeListenerAfter: Boolean = true | |
) : NavigationEvent() | |
data class NavigateAdventureOwnerParticipantMenu( | |
val adventure: Adventure, | |
val onDestinationChangedListener: NavController.OnDestinationChangedListener, | |
val removeListenerAfter: Boolean = true | |
) : NavigationEvent() | |
data class NavigateAdventureOptions( | |
val adventure: Adventure, | |
val partName: Screen, | |
val onDestinationChangedListener: NavController.OnDestinationChangedListener, | |
val removeListenerAfter: Boolean = true | |
): NavigationEvent() | |
data class PopSpecificList(val routes: List<String>, val inclusive: Boolean) : NavigationEvent() | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\data\navigation\NavigationViewModel.kt | |
```kt | |
package com.divadventure.data.navigation | |
import androidx.lifecycle.viewModelScope | |
import com.divadventure.viewmodel.BaseViewModel | |
import dagger.hilt.android.lifecycle.HiltViewModel | |
import kotlinx.coroutines.flow.MutableSharedFlow | |
import kotlinx.coroutines.flow.SharedFlow | |
import kotlinx.coroutines.launch | |
import javax.inject.Inject | |
@HiltViewModel | |
class NavigationViewModel @Inject constructor( | |
) : | |
BaseViewModel<NavigationIntent, NavigationState>(NavigationState()) { | |
private lateinit var _customNavigationStackManager: CustomNavigationStackManager | |
// Navigation event flow for handling navigation actions | |
private val _navigationEvent = MutableSharedFlow<NavigationEvent>(replay = 0) | |
//val navigationEvent: SharedFlow<NavigationEvent> = _navigationEvent | |
fun setNavigationManager(customNavigationStackManager: CustomNavigationStackManager) { | |
_customNavigationStackManager = customNavigationStackManager | |
} | |
/** | |
* Navigate to a destination using the navigation event flow (suspend function) | |
*/ | |
suspend fun navigateSuspend(event: NavigationEvent) { | |
_navigationEvent.emit(event) | |
} | |
/** | |
* Navigate to a destination using the navigation event flow (from non-coroutine context) | |
* This is a convenience method for viewModelScope.launch { navigate(event) } | |
*/ | |
fun navigateFrom(event: NavigationEvent) { | |
viewModelScope.launch { | |
navigateSuspend(event) | |
} | |
} | |
override suspend fun handleIntent(intent: NavigationIntent) { | |
/* | |
when (intent) { | |
*/ | |
/*NavigationIntent.navigateLandingToLogin -> { | |
navigate(NavigationEvent.NavigateTo(Screen.LoginScreen)) | |
}*//* | |
*/ | |
/* | |
NavigationIntent.navigateLandingToSignup -> { | |
navigate(NavigationEvent.NavigateTo(Screen.SignUpScreen)) | |
} | |
*//* | |
*/ | |
/* NavigationIntent.navigateLoginToLanding -> { | |
navigate( | |
NavigationEvent.NavigateTo( | |
Screen.LandingScreen, | |
Screen.LoginScreen, | |
inclusive = true | |
) | |
) | |
}*//* | |
*/ | |
/* | |
NavigationIntent.navigateSignupToLanding -> { | |
navigate( | |
NavigationEvent.NavigateTo( | |
Screen.LandingScreen, | |
Screen.SignUpScreen, | |
inclusive = true | |
) | |
) | |
} | |
*//* | |
*/ | |
/* | |
NavigationIntent.navaigteSignupToHome -> { | |
navigate( | |
NavigationEvent.NavigateTo( | |
Screen.HomeScreen, Screen.LandingScreen, inclusive = true | |
) | |
) | |
} | |
*//* | |
*/ | |
/* | |
NavigationIntent.navigateSplashToLanding -> { | |
navigate( | |
NavigationEvent.NavigateTo( | |
Screen.LandingScreen, | |
Screen.SplashScreen, | |
inclusive = true | |
) | |
) | |
} | |
*//* | |
*/ | |
/* | |
is NavigationIntent.navigateLoginToSignUp -> { | |
navigate(NavigationEvent.NavigateTo(Screen.SignUpScreen)) | |
} | |
*//* | |
*/ | |
/* | |
NavigationIntent.navigationToOnboard -> { | |
navigate( | |
NavigationEvent.NavigateTo( | |
Screen.OnboardingScreen, Screen.LandingScreen, inclusive = true | |
) | |
) | |
} | |
*//* | |
*/ | |
/* | |
NavigationIntent.navigateOnboadringToVerification -> { | |
navigate( | |
NavigationEvent.NavigateTo( | |
Screen.VerificationScreen, Screen.OnboardingScreen, inclusive = true | |
) | |
) | |
} | |
*//* | |
*/ | |
/* | |
NavigationIntent.navigateVerificationToLogin -> navigate( | |
NavigationEvent.NavigateTo( | |
Screen.LoginScreen, Screen.VerificationScreen, inclusive = true | |
) | |
) | |
*//* | |
} | |
*/ | |
} | |
} | |
sealed class NavigationIntent { | |
// object navigateLandingToLogin : NavigationIntent() | |
// object navigateLandingToSignup : NavigationIntent() | |
// object navigateLoginToLanding : NavigationIntent() | |
// object navigateSignupToLanding : NavigationIntent() | |
// object navaigteSignupToHome : NavigationIntent() | |
// object navigationToOnboard : NavigationIntent() | |
// object navigateSplashToLanding : NavigationIntent() | |
// data class navigateLoginToSignUp(val noHistory: Boolean) : NavigationIntent() | |
// object navigateOnboadringToVerification : NavigationIntent() | |
// object navigateVerificationToLogin : NavigationIntent() | |
} | |
data class NavigationState(val value: String = "") | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\data\Repository\AdventureRepository.kt | |
```kt | |
package com.divadventure.data.Repository | |
import com.divadventure.data.AdventureApi | |
import com.divadventure.di.SharedPrefs | |
import com.divadventure.di.UserPrefs.KEY_ID | |
import com.divadventure.di.UserPrefs.KEY_TOKEN | |
import com.divadventure.domain.models.AdventuresResponse | |
import com.divadventure.domain.models.CreateAdventureRequest | |
import com.divadventure.domain.models.CreateAdventureResponse | |
import com.divadventure.domain.models.Filters | |
import javax.inject.Inject | |
class AdventureRepository @Inject constructor( | |
private val adventureApi: AdventureApi, private val sharedPrefs: SharedPrefs | |
) { | |
suspend fun searchAdventures( | |
searchQuery: String?, page: Int, filters: Filters | |
): AdventuresResponse { | |
val options = mutableMapOf<String, String?>() | |
// Add parameters only if they're not null | |
searchQuery?.let { options["query"] = it } | |
// filters.interests?.map { it.id }?.let { options["interests[]"] = it.joinToString(",") } | |
filters.interests?.map { it.id }?.let { | |
it.forEach { options.put("interests[]", it) } | |
} | |
filters.locationLAt?.let { options["location[lat]"] = it.toString() } | |
filters.locationLng?.let { options["location[lng]"] = it.toString() } | |
filters.startDate?.let { options["start_date"] = it } | |
filters.endDate?.let { options["end_date"] = it } | |
filters.state?.let { options["state"] = it.lowercase() } | |
filters.perPage?.let { options["per_page"] = it.toString() } | |
filters.orderBy?.let { | |
when { | |
it.contentEquals("Popular", true) -> { | |
options["order_by"] = "popularity" | |
} | |
it.contentEquals("Recent", true) -> { | |
options["order_by"] = "recently_created" | |
} | |
it.contentEquals("Near me", true) -> { | |
options["order_by"] = "near_me" | |
} | |
} | |
} | |
options["page"] = page.toString() | |
return adventureApi.searchAdventures( | |
bearer = "Bearer ${sharedPrefs.getString(KEY_TOKEN)}", options = options | |
) | |
} | |
suspend fun getGroupedAdventures(group: String, page: Int): AdventuresResponse { | |
val options = mutableMapOf<String, String?>() | |
when { | |
group.contentEquals("ALL", ignoreCase = true) == false -> { | |
options[group.lowercase()] = sharedPrefs.getString(KEY_ID) ?: "" | |
} | |
} | |
options["page"] = page.toString() | |
options["per_page"] = "10" | |
return adventureApi.getAdventures( | |
bearer = "Bearer ${sharedPrefs.getString(KEY_TOKEN)}", options = options | |
) | |
} | |
suspend fun getAllAdventures(page: Int): AdventuresResponse { | |
val options = mutableMapOf<String, String?>() | |
options["page"] = page.toString() | |
return adventureApi.getAdventures( | |
bearer = "Bearer ${sharedPrefs.getString(KEY_TOKEN)}", options = options | |
) | |
} | |
suspend fun getMyAdventures(): AdventuresResponse { | |
return adventureApi.getMyAdventures( | |
bearer = "Bearer ${sharedPrefs.getString(KEY_TOKEN)}", | |
) | |
} | |
suspend fun getCalendarAdventures( | |
group: String, startDate: String, endDate: String | |
): AdventuresResponse { | |
val options = mutableMapOf<String, String?>() | |
options["start_date"] = startDate | |
options["end_date"] = endDate | |
options["per_page"] = "10000" | |
when { | |
!group.contentEquals("ALL", ignoreCase = true) -> { | |
options[group.lowercase()] = sharedPrefs.getString(KEY_ID) ?: "" | |
} | |
} | |
return adventureApi.getAdventures( | |
bearer = "Bearer ${sharedPrefs.getString(KEY_TOKEN)}", options = options | |
) | |
} | |
suspend fun getElseAdventures(profileId: String): AdventuresResponse { | |
return adventureApi.getUserAdventures( | |
bearer = "Bearer ${sharedPrefs.getString(KEY_TOKEN)}", | |
id = profileId | |
) | |
} | |
suspend fun createNewAdventure(createAdventureRequest: CreateAdventureRequest): CreateAdventureResponse { | |
return adventureApi.createAdventure( | |
bearer = "Bearer ${sharedPrefs.getString(KEY_TOKEN)}", | |
createAdventureRequest = createAdventureRequest | |
) | |
} | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\data\Repository\CalendarRepository.kt | |
```kt | |
package com.divadventure.data.Repository | |
class CalendarRepository { | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\data\Repository\InterestsRepository.kt | |
```kt | |
package com.divadventure.data.Repository | |
import com.divadventure.data.InterestsApi | |
import com.divadventure.di.SharedPrefs | |
import com.divadventure.di.UserPrefs.KEY_TOKEN | |
import com.divadventure.domain.models.InterestsResponse | |
import javax.inject.Inject | |
class InterestsRepository @Inject constructor( | |
private val interestsApi: InterestsApi, private val sharedPrefs: SharedPrefs | |
) { | |
suspend fun getInterests(): InterestsResponse { | |
// Fetch the list of interests from the API | |
return interestsApi.getInterests( | |
bearer = "Bearer ${sharedPrefs.getString(KEY_TOKEN)}" | |
) | |
} | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\data\Repository\ProfileRepository.kt | |
```kt | |
package com.divadventure.data.Repository | |
import javax.inject.Inject | |
class ProfileRepository @Inject constructor( | |
) { | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\data\Repository\RequestsRepository.kt | |
```kt | |
package com.divadventure.data.Repository | |
import com.divadventure.data.RequestsApi | |
import com.divadventure.di.SharedPrefs | |
import com.divadventure.di.UserPrefs.KEY_TOKEN | |
import com.divadventure.domain.models.AdventureRequestsResponse | |
import com.divadventure.domain.models.Participant | |
import com.divadventure.domain.models.Request | |
import com.divadventure.domain.models.UsersAdventureRequestResponse | |
import jakarta.inject.Inject | |
class RequestsRepository @Inject constructor( | |
private val sharedPrefs: SharedPrefs, | |
private val requestsApi: RequestsApi | |
) { | |
/** | |
* Fetches all adventure requests for a specific adventure. | |
* | |
* @param adventureId The ID of the adventure for which to fetch requests. | |
*/ | |
suspend fun fetchAllAdventureRequests(adventureId: String): AdventureRequestsResponse { | |
val token = "Bearer ${sharedPrefs.getString(KEY_TOKEN)}" // Fetch bearer token | |
return requestsApi.getAdventureRequests( | |
id = adventureId, bearer = token | |
) // Fetch adventure requests | |
} | |
suspend fun fetchUserAdventureRequests( | |
adventureId: String, userId: String | |
): UsersAdventureRequestResponse { | |
val token = "Bearer ${sharedPrefs.getString(KEY_TOKEN)}" // Fetch bearer token | |
return requestsApi.getAllUserRequests( | |
id = userId, | |
bearer = token, // Use the token variable | |
query = adventureId // Include the necessary query parameter | |
) // Fetch user requests | |
} | |
suspend fun createAdventureInviteRequest( | |
adventureId: String, userId: String | |
): Request { | |
val token = "Bearer ${sharedPrefs.getString(KEY_TOKEN)}" // Fetch bearer token | |
return requestsApi.createAdventureInviteRequest( | |
bearer = token, id = adventureId, requestBody = mapOf("userId" to userId) | |
) // Send an invite request | |
} | |
suspend fun destroyAdventureInviteRequest( | |
adventureId: String, requestId: String | |
) { | |
val token = "Bearer ${sharedPrefs.getString(KEY_TOKEN)}" // Fetch bearer token | |
requestsApi.destroyAdventureInviteRequest( | |
id = adventureId, bearer = token, requestId = requestId | |
) | |
} | |
suspend fun acceptJoinRequest( | |
adventureId: String, requestId: String | |
): com.divadventure.domain.models.AdventureRequestResponse { | |
val token = "Bearer ${sharedPrefs.getString(KEY_TOKEN)}" // Fetch bearer token | |
return requestsApi.acceptJoinRequest( | |
id = adventureId, bearer = token, requestId = requestId | |
) // Accept the join request | |
} | |
suspend fun declineJoinRequest( | |
adventureId: String, requestId: String | |
): com.divadventure.domain.models.AdventureRequestResponse { | |
val token = "Bearer ${sharedPrefs.getString(KEY_TOKEN)}" // Fetch bearer token | |
return requestsApi.declineJoinRequest( | |
id = adventureId, bearer = token, requestId = requestId | |
) | |
} | |
suspend fun createJoinRequest( | |
adventureId: String | |
): List<Participant> { | |
val token = "Bearer ${sharedPrefs.getString(KEY_TOKEN)}" // Fetch bearer token | |
return requestsApi.createJoinRequest( | |
id = adventureId, bearer = token | |
) // Create a join request | |
} | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\data\Repository\UploadImageManager.kt | |
```kt | |
package com.divadventure.data.Repository | |
import com.divadventure.data.UploadApi | |
import com.divadventure.di.SharedPrefs | |
import jakarta.inject.Inject | |
class UploadImageManager @Inject constructor( | |
private val uploadApi: UploadApi, // API service for file uploads | |
private val sharedPrefs: SharedPrefs // Shared preferences for storing token | |
) | |
/* | |
suspend fun uploadImage(file: File): UploadResponse { | |
return try { | |
val token = "Bearer ${sharedPrefs.getString(KEY_TOKEN)}" // Retrieve bearer token | |
val requestBody = | |
file.asRequestBody("image/*".toMediaTypeOrNull()) // Create a request body for the file | |
val multipartBody = MultipartBody.Part.createFormData( | |
"file", file.name, requestBody | |
) // Create a multipart body | |
uploadApi.uploadImage(bearer = token, file = multipartBody) // Call the API | |
} catch (e: Exception) { | |
throw e // Rethrow exception to maintain expected behavior | |
} | |
} | |
*/ | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\data\Repository\UsersRepository.kt | |
```kt | |
package com.divadventure.data.Repository | |
import com.divadventure.data.UsersApi | |
import com.divadventure.di.SharedPrefs | |
import com.divadventure.di.UserPrefs.KEY_TOKEN | |
import com.divadventure.domain.models.FriendsResponse | |
import com.divadventure.domain.models.UsersData | |
import javax.inject.Inject | |
class UsersRepository @Inject constructor( | |
private val usersApi: UsersApi, | |
private val sharedPrefs: SharedPrefs | |
) { | |
/** | |
* Retrieves the list of friends for the current user. | |
* This method uses the `FriendsApi` to fetch the data. | |
*/ | |
suspend fun getFriends(): FriendsResponse { | |
return try { | |
val token = "Bearer ${sharedPrefs.getString(KEY_TOKEN)}" // Fetch the bearer token | |
usersApi.getFriends(bearer = token) // Call the API with the token | |
} catch (e: Exception) { | |
throw e // Rethrow exception to preserve expected behavior | |
} | |
} | |
suspend fun getElseFriends(profileId: String): FriendsResponse { | |
return try { | |
val token = "Bearer ${sharedPrefs.getString(KEY_TOKEN)}" // Fetch the bearer token | |
usersApi.getElseFriends( | |
bearer = token, id = profileId | |
) | |
} catch (e: Exception) { | |
throw e | |
} | |
} | |
suspend fun getUserData(userId: String): UsersData { | |
return try { | |
val token = "Bearer ${sharedPrefs.getString(KEY_TOKEN)}" // Fetch the bearer token | |
usersApi.getUserData(bearer = token, id = userId) // Call the API to fetch user data | |
} catch (e: Exception) { | |
throw e // Rethrow exception to preserve expected behavior | |
} | |
} | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\data\SharedService.kt | |
```kt | |
package com.divadventure.data | |
import com.divadventure.domain.models.AdventureRequestResponse | |
import com.divadventure.domain.models.AdventureRequestsResponse | |
import com.divadventure.domain.models.AdventuresResponse | |
import com.divadventure.domain.models.CreateAdventureRequest | |
import com.divadventure.domain.models.CreateAdventureResponse | |
import com.divadventure.domain.models.FriendsResponse | |
import com.divadventure.domain.models.InterestsResponse | |
import com.divadventure.domain.models.Participant | |
import com.divadventure.domain.models.ReqLogin | |
import com.divadventure.domain.models.ReqOnboard | |
import com.divadventure.domain.models.ReqVerifyEmail | |
import com.divadventure.domain.models.ReqVerifyResetPasswordToken | |
import com.divadventure.domain.models.Request | |
import com.divadventure.domain.models.ResVerifyEmail | |
import com.divadventure.domain.models.ResVerifyResetPasswordToken | |
import com.divadventure.domain.models.SignUpResponse | |
import com.divadventure.domain.models.SignupRequest | |
import com.divadventure.domain.models.UsersAdventureRequestResponse | |
import com.divadventure.domain.models.UsersData | |
import com.divadventure.viewmodel.AuthViewModel.ResetPasswordRequest | |
import com.divadventure.viewmodel.AuthViewModel.ResetPasswordResponse | |
import okhttp3.RequestBody | |
import retrofit2.Call | |
import retrofit2.http.Body | |
import retrofit2.http.DELETE | |
import retrofit2.http.GET | |
import retrofit2.http.Header | |
import retrofit2.http.Headers | |
import retrofit2.http.POST | |
import retrofit2.http.PUT | |
import retrofit2.http.Path | |
import retrofit2.http.Query | |
import retrofit2.http.QueryMap | |
interface SharedService { | |
@POST("auth/sign-up") | |
fun signup(@Body request: SignupRequest): Call<SignUpResponse> | |
@PUT("auth/onboard") | |
fun onboard( | |
@Header("Authorization") bearer: String, @Body request: ReqOnboard | |
): Call<SignUpResponse> | |
@PUT("auth/verify-email") | |
fun verifyEmail( | |
@Header("Authorization") bearer: String, @Body request: ReqVerifyEmail | |
): Call<ResVerifyEmail> | |
@POST("auth/resend-email-verification") | |
fun resendVerificationEmail( | |
@Header("Authorization") bearer: String, | |
): Call<ResVerifyEmail> | |
@PUT("auth/update-email") | |
fun updateEmail( | |
@Header("Authorization") bearer: String, @Body request: ResVerifyEmail | |
): Call<ResVerifyEmail> | |
// Account | |
@GET("account/me") | |
fun getCurrentUser( | |
@Header("Authorization") bearer: String, | |
): Call<SignUpResponse> | |
@POST("auth/login") | |
fun login(@Body request: ReqLogin): Call<SignUpResponse> | |
@POST("auth/forgot-password") | |
fun forgotPassword(@Body request: ResVerifyEmail): Call<Unit> | |
@Headers("Content-Type: application/json") | |
@POST("auth/verify-reset-password-token") | |
fun verifyResetPasswordToken( | |
@Body reqVerifyResetPasswordToken: ReqVerifyResetPasswordToken | |
): Call<ResVerifyResetPasswordToken> | |
@POST("auth/reset-password") | |
fun resetPassword( | |
@Body request: ResetPasswordRequest | |
): Call<ResetPasswordResponse> | |
} | |
interface AdventureApi { | |
// Search adventures with query and filters | |
@GET("adventures/search") | |
suspend fun searchAdventures( | |
@Header("Authorization") bearer: String, | |
@QueryMap options: Map<String, String?> | |
): AdventuresResponse | |
@GET("account/calendar_view") | |
suspend fun getMyAdventures( | |
@Header("Authorization") bearer: String, | |
): AdventuresResponse | |
@GET("adventures") | |
suspend fun getAdventures( | |
@Header("Authorization") bearer: String, | |
@QueryMap options: Map<String, String?> | |
): AdventuresResponse | |
@GET("users/{id}/calendar_view") | |
suspend fun getUserAdventures( | |
@Header("Authorization") bearer: String, | |
@Path("id") id: String | |
): AdventuresResponse | |
@POST("adventures") | |
suspend fun createAdventure( | |
@Header("Authorization") bearer: String, | |
@Body createAdventureRequest: CreateAdventureRequest | |
): CreateAdventureResponse | |
/*// Get all adventures (with no ID query) | |
@GET("adventures") | |
suspend fun getAdventures( | |
@Header("Authorization") bearer: String, | |
): AdventuresResponse | |
// Get adventures owned by a specific user | |
@GET("adventures") | |
suspend fun getOwnedAdventures( | |
@Header("Authorization") bearer: String, @Query("owned") id: String | |
): AdventuresResponse | |
// Get invited adventures | |
@GET("adventures") | |
suspend fun getInvitedAdventures( | |
@Header("Authorization") bearer: String, @Query("invited") id: String | |
): AdventuresResponse | |
// Get joined adventures | |
@GET("adventures") | |
suspend fun getJoinedAdventures( | |
@Header("Authorization") bearer: String, @Query("joined") id: String | |
): AdventuresResponse | |
// Get friends' adventures | |
@GET("adventures") | |
suspend fun getFriendsAdventures( | |
@Header("Authorization") bearer: String, @Query("friends") id: String | |
): AdventuresResponse*/ | |
} | |
interface UsersApi { | |
@GET("users/{id}") | |
suspend fun getUserData( | |
@Header("Authorization") bearer: String, | |
@Path("id") id: String | |
): UsersData | |
@GET("account/friends") | |
suspend fun getFriends( | |
@Header("Authorization") bearer: String | |
): FriendsResponse | |
@GET("users/{id}/friends") | |
suspend fun getElseFriends( | |
@Header("Authorization") bearer: String, | |
@Path("id") id: String | |
): FriendsResponse | |
} | |
interface InterestsApi { | |
@GET("interests") | |
suspend fun getInterests( | |
@Header("Authorization") bearer: String | |
): InterestsResponse | |
} | |
interface UploadApi { | |
@POST("uploader") | |
suspend fun uploadImage( | |
@Header("Authorization") bearer: String, | |
@Body file: RequestBody | |
) | |
} | |
interface CalendarApi { | |
@GET("account/calendar_view") | |
suspend fun getMyCalendar( | |
@Header("Authorization") bearer: String | |
): FriendsResponse | |
} | |
interface RequestsApi { | |
@GET("adventures/{id}/adventure_requests") | |
suspend fun getAdventureRequests( | |
@Header("Authorization") bearer: String, | |
@Path("id") id: String | |
): AdventureRequestsResponse | |
@GET("adventures/{id}/adventure_user_requests") | |
suspend fun getAllUserRequests( | |
@Header("Authorization") bearer: String, | |
@Path("id") id: String, | |
@Query("query") query: String | |
): UsersAdventureRequestResponse | |
@POST("adventures/{id}/invite_requests") | |
suspend fun createAdventureInviteRequest( | |
@Header("Authorization") bearer: String, | |
@Path("id") id: String, | |
@Body requestBody: Map<String, String> | |
): Request | |
@DELETE("adventures/{id}/invite_requests/{request_id}") | |
suspend fun destroyAdventureInviteRequest( | |
@Header("Authorization") bearer: String, | |
@Path("id") id: String, | |
@Path("request_id") requestId: String | |
): AdventureRequestResponse | |
@PUT("adventures/{id}/join_requests/{join_request_id}/accept") | |
suspend fun acceptJoinRequest( | |
@Header("Authorization") bearer: String, | |
@Path("id") id: String, | |
@Path("join_request_id") requestId: String | |
): AdventureRequestResponse | |
@PUT("adventures/{id}/join_requests/{request_id}/decline") | |
suspend fun declineJoinRequest( | |
@Header("Authorization") bearer: String, | |
@Path("id") id: String, | |
@Path("request_id") requestId: String | |
): AdventureRequestResponse | |
@POST("adventures/{id}/join_requests") | |
suspend fun createJoinRequest( | |
@Header("Authorization") bearer: String, | |
@Path("id") id: String | |
): List<Participant> | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\di\AppModule.kt | |
```kt | |
package com.divadventure.di | |
import android.content.Context | |
import com.divadventure.R | |
import com.google.android.libraries.places.api.Places | |
import com.google.android.libraries.places.api.net.PlacesClient | |
import dagger.Module | |
import dagger.Provides | |
import dagger.hilt.InstallIn | |
import dagger.hilt.android.qualifiers.ApplicationContext | |
import dagger.hilt.components.SingletonComponent | |
import retrofit2.Retrofit | |
import retrofit2.converter.gson.GsonConverterFactory | |
import timber.log.Timber | |
import javax.inject.Singleton | |
@Module | |
@InstallIn(SingletonComponent::class) | |
object AppModule { | |
@Provides | |
@Singleton | |
fun provideBaseUrl(): String { | |
val baseUrl = "https://adv-backend-staging-iec9d.ondigitalocean.app/api/v1/" | |
Timber.Forest.d("Providing Base URL: $baseUrl") | |
return baseUrl | |
} | |
@Provides | |
@Singleton | |
fun provideRetrofit(baseUrl: String): Retrofit { | |
Timber.Forest.d("Building Retrofit instance with Base URL: $baseUrl") | |
return Retrofit.Builder() | |
.baseUrl(baseUrl) | |
.addConverterFactory(GsonConverterFactory.create()) | |
.build() | |
} | |
@Provides | |
@Singleton | |
fun providePlacesClient(@ApplicationContext context: Context): PlacesClient { | |
// Initialize Places with the context | |
Places.initialize(context, context.getString(R.string.map_id)) | |
return Places.createClient(context) | |
} | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\di\FeaturesModules\AdventuresModule.kt | |
```kt | |
package com.divadventure.di.FeaturesModules | |
import com.divadventure.data.Repository.AdventureRepository | |
import com.divadventure.di.SharedPrefs | |
import com.divadventure.domain.usecase.AdventuresUseCase | |
import dagger.Module | |
import dagger.Provides | |
import dagger.hilt.InstallIn | |
import dagger.hilt.components.SingletonComponent | |
import javax.inject.Singleton | |
@Module | |
@InstallIn(SingletonComponent::class) | |
object AdventuresModule { | |
@Provides | |
@Singleton | |
fun provideAdventuresUseCase( | |
adventureRepository: AdventureRepository, | |
sharedPrefs: SharedPrefs | |
): AdventuresUseCase { | |
return AdventuresUseCase(adventureRepository, sharedPrefs) | |
} | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\di\FeaturesModules\DateModule.kt | |
```kt | |
package com.divadventure.di.FeaturesModules | |
import com.divadventure.data.Repository.AdventureRepository | |
import com.divadventure.di.SharedPrefs | |
import com.divadventure.domain.usecase.CalendarUseCase | |
import dagger.Module | |
import dagger.Provides | |
import dagger.hilt.InstallIn | |
import dagger.hilt.android.components.ViewModelComponent | |
import dagger.hilt.components.SingletonComponent | |
import javax.inject.Singleton | |
@Module | |
@InstallIn(SingletonComponent::class) // Correct scope for application-wide dependencies | |
object DateModule { | |
@Provides | |
@Singleton | |
fun provideCalendarUseCase( | |
adventureRepository: AdventureRepository, | |
sharedPrefs: SharedPrefs | |
): CalendarUseCase { | |
return CalendarUseCase(adventureRepository, sharedPrefs) | |
} | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\di\FeaturesModules\LocationModule.kt | |
```kt | |
package com.divadventure.di.FeaturesModules | |
import com.divadventure.data.Repository.AdventureRepository | |
import com.divadventure.di.SharedPrefs | |
import com.divadventure.domain.usecase.AdventuresUseCase | |
import com.divadventure.domain.usecase.LocationsUseCase | |
import com.google.android.libraries.places.api.net.PlacesClient | |
import dagger.Module | |
import dagger.Provides | |
import dagger.hilt.InstallIn | |
import dagger.hilt.android.components.ViewModelComponent | |
import dagger.hilt.components.SingletonComponent | |
import javax.inject.Singleton | |
@Module | |
@InstallIn(SingletonComponent::class) | |
object LocationModule { | |
@Provides | |
@Singleton | |
fun provideLocationsUseCase(placesClient: PlacesClient): LocationsUseCase { | |
return LocationsUseCase(placesClient) | |
} | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\di\FeaturesModules\ProfileModule.kt | |
```kt | |
package com.divadventure.di.FeaturesModules | |
import com.divadventure.data.Repository.UsersRepository | |
import com.divadventure.domain.usecase.UsersUseCase | |
import dagger.Module | |
import dagger.Provides | |
import dagger.hilt.InstallIn | |
import dagger.hilt.components.SingletonComponent | |
import javax.inject.Singleton | |
@Module | |
@InstallIn(SingletonComponent::class) | |
object ProfileModule { | |
@Provides | |
@Singleton | |
fun provideFriendsUseCase( | |
usersRepository: UsersRepository | |
): UsersUseCase { | |
return UsersUseCase( | |
usersRepository | |
) | |
} | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\di\FeaturesModules\RequestsModule.kt | |
```kt | |
package com.divadventure.di.FeaturesModules | |
import com.divadventure.data.Repository.RequestsRepository | |
import com.divadventure.domain.usecase.RequestsUseCase | |
import com.divadventure.domain.usecase.UsersUseCase | |
import dagger.Module | |
import dagger.Provides | |
import dagger.hilt.InstallIn | |
import dagger.hilt.components.SingletonComponent | |
import javax.inject.Singleton | |
@Module | |
@InstallIn(SingletonComponent::class) | |
object RequestsModule { | |
@Provides | |
@Singleton | |
fun provideRequestsUseCase( | |
requestsRepository: RequestsRepository | |
): RequestsUseCase { | |
return RequestsUseCase( | |
requestsRepository = requestsRepository | |
) | |
} | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\di\HomeModule.kt | |
```kt | |
package com.divadventure.di | |
import com.divadventure.data.InterestsApi | |
import com.divadventure.data.Repository.AdventureRepository | |
import com.divadventure.data.Repository.InterestsRepository | |
import com.divadventure.domain.usecase.AdventuresUseCase | |
import com.divadventure.domain.usecase.CalendarUseCase | |
import com.divadventure.domain.usecase.InterestsUseCase | |
import com.divadventure.domain.usecase.LocationsUseCase | |
import com.google.android.libraries.places.api.net.PlacesClient | |
import dagger.Module | |
import dagger.Provides | |
import dagger.hilt.InstallIn | |
import dagger.hilt.android.components.ViewModelComponent | |
import dagger.hilt.components.SingletonComponent | |
import javax.inject.Singleton | |
@Module | |
@InstallIn(ViewModelComponent::class) | |
object HomeModule { | |
@Provides | |
fun provideInterestsRepository( | |
interestsApi: InterestsApi, | |
sharedPrefs: SharedPrefs | |
): InterestsRepository { | |
return InterestsRepository(interestsApi, sharedPrefs) | |
} | |
@Provides | |
fun provideInterestsUseCase( | |
interestsRepository: InterestsRepository | |
): InterestsUseCase { | |
return InterestsUseCase(interestsRepository) | |
} | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\di\NetworkModule.kt | |
```kt | |
package com.divadventure.di | |
import com.divadventure.data.AdventureApi | |
import com.divadventure.data.InterestsApi | |
import com.divadventure.data.Repository.AdventureRepository | |
import com.divadventure.data.Repository.RequestsRepository | |
import com.divadventure.data.Repository.UsersRepository | |
import com.divadventure.data.RequestsApi | |
import com.divadventure.data.SharedService | |
import com.divadventure.data.UsersApi | |
import dagger.Module | |
import dagger.Provides | |
import dagger.hilt.InstallIn | |
import dagger.hilt.components.SingletonComponent | |
import retrofit2.Retrofit | |
import timber.log.Timber | |
import javax.inject.Singleton | |
@Module | |
@InstallIn(SingletonComponent::class) // Fix scope to Application-wide | |
object NetworkModule { | |
@Provides | |
@Singleton | |
fun provideAuthService(retrofit: Retrofit): SharedService { | |
Timber.Forest.d("Creating SharedService instance from Retrofit") | |
return retrofit.create(SharedService::class.java) | |
} | |
@Provides | |
@Singleton | |
fun provideAdventureApi(retrofit: Retrofit): AdventureApi { | |
Timber.Forest.d("Creating AdventureApi instance from Retrofit") | |
return retrofit.create(AdventureApi::class.java) | |
} | |
@Provides | |
@Singleton | |
fun provideAdventureRepository( | |
adventureApi: AdventureApi, | |
sharedPrefs: SharedPrefs | |
): AdventureRepository { | |
Timber.Forest.d("Providing AdventureRepository instance") | |
return AdventureRepository(adventureApi, sharedPrefs) | |
} | |
// New additions for FriendsApi and FriendsRepository | |
@Provides | |
@Singleton | |
fun provideFriendsApi(retrofit: Retrofit): UsersApi { | |
Timber.Forest.d("Creating FriendsApi instance from Retrofit") | |
return retrofit.create(UsersApi::class.java) | |
} | |
@Provides | |
@Singleton | |
fun provideInterestsApi(retrofit: Retrofit): InterestsApi { | |
return retrofit.create(InterestsApi::class.java) | |
} | |
@Provides | |
@Singleton | |
fun provideFriendsRepository( | |
usersApi: UsersApi, | |
sharedPrefs: SharedPrefs | |
): UsersRepository { | |
Timber.Forest.d("Providing FriendsRepository instance") | |
return UsersRepository(usersApi, sharedPrefs) | |
} | |
@Provides | |
@Singleton | |
fun provideRequestsRepository( | |
requestsApi: RequestsApi, sharedPrefs: SharedPrefs | |
): RequestsRepository { | |
Timber.Forest.d("Providing RequestsRepository instance") | |
return RequestsRepository(sharedPrefs, requestsApi) | |
} | |
@Provides | |
@Singleton | |
fun provideRequestsApi(retrofit: Retrofit): RequestsApi { | |
Timber.Forest.d("Creating RequestsApi instance from Retrofit") | |
return retrofit.create(RequestsApi::class.java) | |
} | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\di\SharedPreferencesModule.kt | |
```kt | |
package com.divadventure.di | |
import android.content.Context | |
import android.content.SharedPreferences | |
import timber.log.Timber | |
import dagger.Module | |
import dagger.Provides | |
import dagger.hilt.InstallIn | |
import dagger.hilt.android.qualifiers.ApplicationContext | |
import dagger.hilt.components.SingletonComponent | |
import javax.inject.Inject | |
import javax.inject.Singleton | |
@Module | |
@InstallIn(SingletonComponent::class) | |
object SharedPreferencesModule { | |
@Provides | |
@Singleton | |
fun provideSharedPreferences(@ApplicationContext context: Context): SharedPreferences { | |
Timber.d("Providing SharedPreferences instance") | |
return context.getSharedPreferences("my_app_prefs", Context.MODE_PRIVATE) | |
} | |
@Provides | |
@Singleton | |
fun provideSharedPrefs(sharedPreferences: SharedPreferences): SharedPrefs { | |
Timber.d("Providing SharedPrefs instance") | |
return SharedPrefs(sharedPreferences) | |
} | |
} | |
class SharedPrefs @Inject constructor(private val sharedPreferences: SharedPreferences) { | |
fun setString(key: String, value: String?) { | |
Timber.d("Setting String value for key: $key, value: $value") | |
sharedPreferences.edit().putString(key, value).apply() | |
} | |
fun setLong(key: String, value: Long) { | |
Timber.d("Setting Long value for key: $key, value: $value") | |
sharedPreferences.edit().putLong(key, value).apply() | |
} | |
fun getLong(key: String, defaultValue: Long): Long { | |
val value = sharedPreferences.getLong(key, defaultValue) | |
Timber.d("Getting Long value for key: $key, value: $value") | |
return value | |
} | |
fun getString(key: String): String? { | |
val value = sharedPreferences.getString(key, null) | |
Timber.d("Getting String value for key: $key, value: $value") | |
return value | |
} | |
fun setInt(key: String, value: Int) { | |
Timber.d("Setting Int value for key: $key, value: $value") | |
sharedPreferences.edit().putInt(key, value).apply() | |
} | |
fun getInt(key: String, defaultValue: Int): Int { | |
val value = sharedPreferences.getInt(key, defaultValue) | |
Timber.d("Getting Int value for key: $key, value: $value") | |
return value | |
} | |
fun setBoolean(key: String, value: Boolean) { | |
Timber.d("Setting Boolean value for key: $key, value: $value") | |
sharedPreferences.edit().putBoolean(key, value).apply() | |
} | |
fun getBoolean(key: String, defaultValue: Boolean): Boolean { | |
val value = sharedPreferences.getBoolean(key, defaultValue) | |
Timber.d("Getting Boolean value for key: $key, value: $value") | |
return value | |
} | |
} | |
public object AuthPrefs { | |
const val VERIFICATION_PASSED_BOOLEAN = "verification_passed" | |
// const val SEND_OTP_TIME_MILLIS = "send_otp_time_millis" | |
// const val ONBOARD_PASSED_BOOLEAN = "onboard_passed" | |
// const val FIRST_TIME_BOOLEAN = "first_time" | |
} | |
public object UserPrefs { | |
const val KEY_ID = "id" | |
const val KEY_AVATAR = "avatar" | |
const val KEY_FIRST_NAME = "first_name" | |
const val KEY_LAST_NAME = "last_name" | |
const val KEY_USERNAME = "username" | |
const val KEY_EMAIL = "email" | |
const val KEY_BIRTH_DATE = "birth_date" | |
const val KEY_BIO = "bio" | |
const val KEY_REFRESH_TOKEN = "refresh_token" | |
const val KEY_TOKEN = "token" | |
const val KEY_PLATFORM = "platform_token" | |
const val KEY_TOKEN_EXPIRES_AT = "token_expires_at" | |
const val KEY_REFRESH_TOKEN_EXPIRES_AT = "refresh_token_expires_at" | |
const val KEY_LOCATION = "location" | |
const val KEY_BIO_PRIVACY = "bio_privacy" | |
const val KEY_LOCATION_PRIVACY = "location_privacy" | |
const val KEY_BIRTH_DATE_PRIVACY = "birth_date_privacy" | |
const val KEY_FRIENDS_PRIVACY = "friends_privacy" | |
const val KEY_ADVENTURES_PRIVACY = "adventures_privacy" | |
} | |
/** | |
// Example of injection in a class | |
import javax.inject.Inject | |
class MyDataRepository @Inject constructor(private val sharedPrefs: SharedPrefs) { | |
fun saveUserName(username: String) { | |
sharedPrefs.setString("username", username) | |
} | |
fun getUserName(): String { | |
return sharedPrefs.getString("username", "") | |
} | |
}*/ | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\domain\models\AdventureModels.kt | |
```kt | |
package com.divadventure.domain.models | |
import com.google.gson.annotations.SerializedName | |
data class CreateAdventureResponse( | |
val data: Adventure | |
) | |
data class CreateAdventureRequest( | |
val title: String, | |
val description: String, | |
val banner: String, | |
@SerializedName("privacy_type") | |
val privacyType: Int, | |
@SerializedName("starts_at") | |
val startsAt: String, | |
@SerializedName("ends_at") | |
val endsAt: String, | |
val deadline: String, | |
@SerializedName("join_request_needed") | |
val joinRequestNeeded: Boolean, | |
@SerializedName("location_attributes") | |
val locationAttributes: LocationAttributes, | |
val interests: List<String> | |
) | |
data class LocationAttributes( | |
val lng: String, | |
val lat: String | |
) | |
data class AdventuresResponse( | |
@SerializedName("adventures") val adventures: List<Adventure>, | |
@SerializedName("meta") val meta: Meta | |
) | |
data class Adventure( | |
@SerializedName("id") val id: String, | |
@SerializedName("title") val title: String, | |
@SerializedName("description") val description: String, | |
@SerializedName("banner") val banner: String, | |
@SerializedName("starts_at") val startsAt: String, | |
@SerializedName("ends_at") val endsAt: String, | |
@SerializedName("privacy_type") val privacyType: String, | |
@SerializedName("deadline") val deadline: String, | |
@SerializedName("join_request_needed") val joinRequestNeeded: Boolean, | |
@SerializedName("owner_id") val ownerId: String, | |
@SerializedName("state") val state: String, | |
@SerializedName("current_user_adventurer_id") val currentUserAdventurerId: String?, | |
@SerializedName("adventure_request") val adventureRequest: List<AdventureRequest>?, | |
@SerializedName("adventurers_count") val adventurersCount: Int, | |
@SerializedName("adventurers") val adventurers: List<Adventurer>, | |
@SerializedName("interests") val interests: List<Interest>, | |
@SerializedName("location") val location: Location, | |
@Transient var adventureType: AdventureType? = null | |
) | |
enum class AdventureType { | |
Manage, Join, Going, Pending, Leave | |
} | |
data class Interest( | |
@SerializedName("id") val id: String, | |
@SerializedName("name") val name: String, | |
@SerializedName("created_at") val createdAt: String?, | |
@SerializedName("updated_at") val updatedAt: String? | |
) | |
// data class AdventureRequest( | |
// @SerializedName("adventure_request") val adventureRequest: List<Request> | |
// ) { | |
data class AdventureRequest( | |
@SerializedName("id") val id: String, | |
@SerializedName("user") val user: User, | |
@SerializedName("adventurer") val adventurer: String?, // Nullable since the value is "null" | |
@SerializedName("adventure") val adventure: Adventure, | |
@SerializedName("accepted_at") val acceptedAt: String?, // Nullable since it can be null | |
@SerializedName("declined_at") val declinedAt: String?, // Nullable since it can be null | |
@SerializedName("type") val type: String, | |
@SerializedName("status") val status: String, | |
@SerializedName("created_at") val createdAt: String | |
) { | |
data class User( | |
@SerializedName("id") val id: String, | |
@SerializedName("avatar") val avatar: String, | |
@SerializedName("username") val username: String, | |
@SerializedName("first_name") val firstName: String, | |
@SerializedName("last_name") val lastName: String | |
) | |
data class Adventure( | |
@SerializedName("id") val id: String, | |
@SerializedName("banner") val banner: String, | |
@SerializedName("title") val title: String | |
) | |
} | |
// } | |
data class Adventurer( | |
@SerializedName("id") val id: String, | |
@SerializedName("role_name") val roleName: String, | |
@SerializedName("role_id") val roleId: String, | |
@SerializedName("user_id") val userId: String, | |
@SerializedName("avatar") val avatar: String, | |
@SerializedName("first_name") val firstName: String, | |
@SerializedName("last_name") val lastName: String, | |
@SerializedName("username") val username: String, | |
@SerializedName("status_with_user") val statusWithUser: String | |
) | |
data class Location( | |
@SerializedName("id") val id: String, | |
@SerializedName("locationable_type") val locationableType: String, | |
@SerializedName("locationable_id") val locationableId: String, | |
@SerializedName("lng") val lng: Double, | |
@SerializedName("lat") val lat: Double | |
) | |
data class Meta( | |
@SerializedName("current_page") val currentPage: Int, | |
@SerializedName("next_page") val nextPage: Int?, | |
@SerializedName("prev_page") val prevPage: Int?, | |
@SerializedName("total_pages") val totalPages: Int, | |
@SerializedName("total_entries") val totalEntries: Int | |
) | |
data class Filters( | |
var interests: List<Interest>? = null, | |
var locationLAt: Double? = null, | |
var locationLng: Double? = null, | |
var startDate: String? = null, | |
var endDate: String? = null, | |
var state: String? = null, | |
var page: Int? = null, | |
var perPage: Int? = null, | |
var orderBy: String? = null | |
) | |
data class InterestsResponse( | |
@SerializedName("interests") val interests: List<Interest>, | |
@SerializedName("meta") val meta: Meta | |
) | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\domain\models\AuthModels.kt | |
```kt | |
package com.divadventure.domain.models | |
import com.google.gson.annotations.SerializedName | |
data class SignupRequest( | |
val email: String, | |
val password: String, | |
val password_confirmation: String, | |
val platform: String = "Android" // Default value for the platform | |
) | |
data class Message( | |
@SerializedName("message") val message: String | |
) | |
data class SignUpResponse( | |
@SerializedName("data") val data: UserData | |
) | |
data class UserData( | |
@SerializedName("id") val id: String, | |
@SerializedName("avatar") val avatar: String?, | |
@SerializedName("first_name") val firstName: String?, | |
@SerializedName("last_name") val lastName: String?, | |
@SerializedName("username") val username: String?, | |
@SerializedName("email") val email: String, | |
@SerializedName("birthdate") val birthdate: String?, | |
@SerializedName("bio") val bio: String?, | |
@SerializedName("location") val location: Location?, | |
@SerializedName("privacy_settings") val privacySettings: PrivacySettings, | |
@SerializedName("current_access_token") val currentAccessToken: CurrentAccessToken | |
) | |
data class PrivacySettings( | |
@SerializedName("bio") val bio: String, | |
@SerializedName("birthdate") val birthdate: String, | |
@SerializedName("adventures") val adventures: String, | |
@SerializedName("friends") val friends: String, | |
@SerializedName("location") val location: String | |
) | |
data class CurrentAccessToken( | |
@SerializedName("platform") val platform: String, | |
@SerializedName("token") val token: String, | |
@SerializedName("refresh_token") val refreshToken: String, | |
@SerializedName("expires_at") val expiresAt: String, | |
@SerializedName("refresh_token_expires_at") val refreshTokenExpiresAt: String | |
) | |
data class ReqOnboard( | |
@SerializedName("first_name") | |
val firstName: String, | |
@SerializedName("last_name") | |
val lastName: String, | |
@SerializedName("username") | |
val username: String | |
) | |
data class ReqVerifyEmail( | |
@SerializedName("token") | |
val token: String | |
) | |
data class ResVerifyEmail( | |
@SerializedName("email") | |
val email: String | |
) | |
data class ReqLogin( | |
val email: String, | |
val password: String, | |
val platform: String = "Android" | |
) | |
data class ReqVerifyResetPasswordToken(val token: String) | |
data class ResVerifyResetPasswordToken(val email: String) | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\domain\models\Requests.kt | |
```kt | |
package com.divadventure.domain.models | |
import com.google.gson.annotations.SerializedName | |
data class AdventureRequestsResponse( | |
@SerializedName("requests") val requests: List<Request>, | |
@SerializedName("meta") val meta: Meta | |
) | |
data class Request( | |
@SerializedName("id") val id: String, | |
@SerializedName("user") val user: UserSpecs, | |
@SerializedName("adventurer") val adventurer: AdventurerSpecs?, // Make this nullable | |
@SerializedName("adventure") val adventure: AdventureSpecs, | |
@SerializedName("accepted_at") val acceptedAt: String?, // nullable | |
@SerializedName("declined_at") val declinedAt: String?, // nullable | |
@SerializedName("type") val type: String, | |
@SerializedName("status") val status: String, | |
@SerializedName("created_at") val createdAt: String | |
) | |
data class UserSpecs( | |
@SerializedName("id") val id: String, | |
@SerializedName("avatar") val avatar: String, | |
@SerializedName("username") val username: String, | |
@SerializedName("first_name") val firstName: String, | |
@SerializedName("last_name") val lastName: String | |
) | |
data class AdventurerSpecs( | |
@SerializedName("id") val id: String, | |
@SerializedName("avatar") val avatar: String, | |
@SerializedName("username") val username: String, | |
@SerializedName("first_name") val firstName: String, | |
@SerializedName("last_name") val lastName: String | |
) | |
data class AdventureSpecs( | |
@SerializedName("id") val id: String, | |
@SerializedName("banner") val banner: String, | |
@SerializedName("title") val title: String | |
) | |
data class Participant( | |
@SerializedName("id") val id: String, | |
// "participant" or "owner" | |
@SerializedName("role_name") val roleName: String, | |
@SerializedName("role_id") val roleId: String, | |
@SerializedName("user_id") val userId: String, | |
// "friends" or "not friends" | |
@SerializedName("avatar") val avatar: String, | |
@SerializedName("first_name") val firstName: String, | |
@SerializedName("last_name") val lastName: String, | |
@SerializedName("username") val username: String, | |
@SerializedName("status_with_user") val statusWithUser: String | |
) | |
data class UsersAdventureRequestResponse( | |
@SerializedName("users") val users: List<AdventureUser>, | |
@SerializedName("meta") val meta: Meta | |
) | |
data class AdventureUser( | |
@SerializedName("id") val id: String, | |
@SerializedName("avatar") val avatar: String, | |
@SerializedName("first_name") val firstName: String, | |
@SerializedName("last_name") val lastName: String, | |
@SerializedName("username") val username: String, | |
@SerializedName("birthdate") val birthdate: String?, | |
@SerializedName("bio") val bio: String?, | |
@SerializedName("location") val location: Location?, // (nullable) | |
@SerializedName("status_with_user") val statusWithUser: String, | |
@SerializedName("adventure_request") val adventureRequest: List<AdventureRequest> | |
) | |
data class AdventureRequestResponse( | |
@SerializedName("data") | |
val data: List<Participant> | |
) | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\domain\models\UsersModels.kt | |
```kt | |
package com.divadventure.domain.models | |
import com.google.gson.annotations.SerializedName | |
data class FriendsResponse( | |
@SerializedName("friends") val friends: List<Friend>, | |
@SerializedName("meta") val meta: Meta | |
) | |
data class Friend( | |
@SerializedName("id") val id: String, | |
@SerializedName("avatar") val avatar: String, | |
@SerializedName("first_name") val firstName: String, | |
@SerializedName("last_name") val lastName: String, | |
@SerializedName("username") val username: String, | |
@SerializedName("birthdate") val birthdate: String?, // Nullable since it can be `null` | |
@SerializedName("status_with_user") val statusWithUser: String | |
) | |
data class UserProfile( | |
@SerializedName("id") val id: String, | |
@SerializedName("avatar") val avatar: String?, | |
@SerializedName("first_name") val firstName: String, | |
@SerializedName("last_name") val lastName: String, | |
@SerializedName("username") val username: String, | |
@SerializedName("birthdate") val birthdate: String?, | |
@SerializedName("bio") val bio: String?, | |
@SerializedName("location") val location: Location?, | |
@SerializedName("status_with_user") val statusWithUser: String?, | |
@SerializedName("current_access_token") val currentAccessToken: CurrentAccessToken? | |
) | |
data class UsersData( | |
val data: UserProfile | |
) | |
/* | |
data class Meta( | |
@SerializedName("current_page") val currentPage: Int, | |
@SerializedName("next_page") val nextPage: Int?, // Nullable since it can be `null` | |
@SerializedName("prev_page") val prevPage: Int?, // Nullable since it can be `null` | |
@SerializedName("total_pages") val totalPages: Int, | |
@SerializedName("total_entries") val totalEntries: Int | |
) | |
*/ | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\domain\usecase\AdventuresUseCase.kt | |
```kt | |
package com.divadventure.domain.usecase | |
import com.divadventure.data.Repository.AdventureRepository | |
import com.divadventure.di.SharedPrefs | |
import com.divadventure.di.UserPrefs.KEY_ID | |
import com.divadventure.domain.models.Adventure | |
import com.divadventure.domain.models.AdventureType | |
import com.divadventure.domain.models.AdventuresResponse | |
import com.divadventure.domain.models.CreateAdventureRequest | |
import com.divadventure.domain.models.CreateAdventureResponse | |
import com.divadventure.domain.models.Filters | |
import com.divadventure.domain.models.LocationAttributes | |
import java.time.LocalDate | |
import java.time.LocalDateTime | |
import java.time.ZoneId | |
import java.time.format.DateTimeFormatter | |
import java.util.Locale | |
import javax.inject.Inject | |
class AdventuresUseCase @Inject constructor( | |
private val adventureRepository: AdventureRepository, private val sharedPrefs: SharedPrefs | |
) { | |
// Use case for searching adventures | |
// This use case takes a search query as input and returns a list of adventures that match the query. | |
// If no adventures are found, it returns a failure result with a NoSuchElementException. | |
// If an exception occurs during the search, it returns a failure result with the exception. | |
// If the search query is empty, it returns a failure result with an IllegalArgumentException. | |
// Input: searchQuery | |
// Output: Result<List<Adventure>> | |
suspend fun search( | |
searchQuery: String?, page: Int, filters: Filters | |
): Result<AdventuresResponse> { | |
return try { | |
val adventures = adventureRepository.searchAdventures( | |
searchQuery, page, filters | |
) | |
// if (adventures.adventures.isEmpty()) { | |
// Result.failure(NoSuchElementException("No adventures found for the query: $searchQuery")) | |
// } | |
// else { | |
Result.success(adventures) | |
// } | |
} catch (e: Exception) { | |
Result.failure(e) | |
} | |
} | |
suspend fun getAllAdventures(page: Int): Result<AdventuresResponse> { | |
return try { | |
val adventures = adventureRepository.getAllAdventures(page) | |
return Result.success(adventures) | |
} catch (e: Exception) { | |
return Result.failure(e) | |
} | |
} | |
suspend fun fetchGroupedAdventures( | |
group: String, page: Int, startDate: String? = null, endDate: String? = null | |
): Result<AdventuresResponse> { | |
return try { | |
val adventuresResponse = adventureRepository.getGroupedAdventures(group, page) | |
Result.success(adventuresResponse) | |
} catch (e: Exception) { | |
Result.failure(e) | |
} | |
} // Close 'fetchGroupedAdventures' | |
suspend fun getMyAdventures(): Result<AdventuresResponse> { | |
return try { | |
val adventures = adventureRepository.getMyAdventures() | |
Result.success(adventures) | |
} catch (e: Exception) { | |
Result.failure(e) | |
} | |
} // Close 'getMyAdventures' | |
suspend fun getElseAdventures(profileId: String): Result<AdventuresResponse> { | |
return try { | |
val adventures = adventureRepository.getElseAdventures(profileId) | |
Result.success(adventures) | |
} catch (e: Exception) { | |
Result.failure(e) | |
} | |
} | |
suspend fun fetchCalendarAdventures( | |
group: String, startDate: String, endDate: String | |
): Result<AdventuresResponse> { | |
return try { | |
val adventures = adventureRepository.getCalendarAdventures(group, startDate, endDate) | |
Result.success(adventures) | |
} catch (e: Exception) { | |
Result.failure(e) | |
} | |
// Close 'addFilters' | |
} // Close 'AdventuresUseCase' | |
suspend fun createAdventure( | |
title: String, | |
description: String, | |
banner: String, | |
privacyType: Int, | |
startsAt: String, | |
endsAt: String, | |
deadline: String, | |
joinRequestNeeded: Boolean, | |
locationAttributes: LocationAttributes, | |
interests: List<String> = mutableListOf<String>() | |
): Result<CreateAdventureResponse> { | |
return try { | |
val request = createAdventureRequestFromParams( | |
title, description, banner, privacyType, | |
startsAt, endsAt, deadline, | |
joinRequestNeeded, locationAttributes, interests | |
) | |
val response = adventureRepository.createNewAdventure(request) | |
Result.success(response) | |
} catch (e: Exception) { | |
Result.failure(e) | |
} | |
} | |
/** | |
* Converts adventure parameters into a CreateAdventureRequest model with proper date formatting | |
*/ | |
private fun createAdventureRequestFromParams( | |
title: String, | |
description: String, | |
banner: String, | |
privacyType: Int, | |
startsAt: String, | |
endsAt: String, | |
deadline: String, | |
joinRequestNeeded: Boolean, | |
locationAttributes: LocationAttributes, | |
interests: List<String> | |
): CreateAdventureRequest { | |
// Convert startsAt and endsAt strings to Instant objects | |
val formatter = DateTimeFormatter.ofPattern("MMM dd yyyy - h:mm a", Locale.ENGLISH) | |
val startsAtInstant = LocalDateTime.parse(startsAt, formatter) | |
.atZone(ZoneId.systemDefault()) | |
.toInstant() | |
val endsAtInstant = LocalDateTime.parse(endsAt, formatter) | |
.atZone(ZoneId.systemDefault()) | |
.toInstant() | |
// Create formatter that can handle dates without leading zeros | |
val dateFormatter = DateTimeFormatter.ofPattern("yyyy-M-d", Locale.ENGLISH) | |
val deadlineInstant = try { | |
// Try standard ISO format first | |
LocalDate.parse(deadline).atStartOfDay(ZoneId.systemDefault()).toInstant() | |
} catch (e: Exception) { | |
// If that fails, try with custom formatter for dates without leading zeros | |
LocalDate.parse(deadline, dateFormatter).atStartOfDay(ZoneId.systemDefault()).toInstant() | |
} | |
return CreateAdventureRequest( | |
title = title, | |
description = description, | |
banner = banner, | |
privacyType = privacyType, | |
startsAt = startsAtInstant.toString(), | |
endsAt = endsAtInstant.toString(), | |
deadline = deadlineInstant.toString(), | |
joinRequestNeeded = joinRequestNeeded, | |
locationAttributes = locationAttributes, | |
interests = interests | |
) | |
} | |
/** | |
* Data class to hold adventure parameters with formatted date strings | |
*/ | |
data class AdventureParams( | |
val title: String, | |
val description: String, | |
val banner: String, | |
val privacyType: Int, | |
val startsAt: String, | |
val endsAt: String, | |
val deadline: String, | |
val joinRequestNeeded: Boolean, | |
val locationAttributes: LocationAttributes, | |
val interests: List<String> | |
) | |
fun checkAdventureType(adventure: Adventure): AdventureType { | |
return when { | |
// adventureIsJoinable(adventure) -> AdventureType.Join | |
adventureIsGoing(adventure) -> AdventureType.Going | |
adventureIsLeaveAble(adventure) -> AdventureType.Leave | |
adventureIsManageable(adventure) -> AdventureType.Manage | |
adventureIsPendingable(adventure) -> AdventureType.Pending | |
else -> { | |
AdventureType.Join | |
// Timber.e(adventure.toString()) | |
// throw IllegalStateException("Unknown Adventure state") | |
} | |
} | |
} | |
private fun adventureIsPendingable(adventure: Adventure): Boolean { | |
return adventure.adventureRequest != null && adventure.adventureRequest.isNotEmpty() && adventure.adventureRequest.any { | |
it.status.equals( | |
"PENDING", | |
true | |
) | |
} && adventure.adventureRequest.any { | |
it.type.equals( | |
"JoinRequest", | |
true | |
) | |
} | |
} | |
private fun adventureIsManageable(adventure: Adventure): Boolean { | |
return adventure.ownerId.equals(sharedPrefs.getString(KEY_ID) ?: "", true) || ( | |
adventure.adventurersCount > 1 && adventure.adventurers.any { | |
it.roleName.equals("participant", true) && it.userId == sharedPrefs.getString( | |
KEY_ID | |
) | |
}) | |
} | |
private fun adventureIsLeaveAble(adventure: Adventure): Boolean { | |
return adventure.adventureRequest != null && | |
adventure.adventureRequest.isNotEmpty() && | |
adventure.adventureRequest.any { | |
it.status.equals( | |
"PENDING", | |
true | |
) | |
} && adventure.adventureRequest.any { | |
it.type.equals( | |
"accepted", | |
true | |
) | |
} | |
} | |
private fun adventureIsGoing(adventure: Adventure): Boolean { | |
return adventure.adventureRequest != null | |
&& adventure.adventureRequest.isNotEmpty() | |
&& adventure.adventureRequest.any { | |
it.status.equals( | |
"pending", | |
true | |
) | |
} | |
&& adventure.adventureRequest.any { | |
it.type.equals( | |
"InviteRequest", | |
true | |
) | |
} | |
} | |
private fun adventureIsJoinable(adventure: Adventure): Boolean { | |
return adventure.adventureRequest != null | |
&& adventure.adventureRequest.isNotEmpty() | |
&& adventure.adventureRequest.any { | |
it.status.equals( | |
"PENDING", | |
true | |
) | |
} | |
&& adventure.adventureRequest.any { | |
it.type.equals( | |
"accepted", | |
true | |
) | |
} | |
} | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\domain\usecase\CalendarUseCase.kt | |
```kt | |
package com.divadventure.domain.usecase | |
import com.divadventure.data.Repository.AdventureRepository | |
import com.divadventure.di.SharedPrefs | |
import javax.inject.Inject | |
class CalendarUseCase @Inject constructor( | |
private val adventureRepository: AdventureRepository, | |
private val sharedPrefs: SharedPrefs | |
) { | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\domain\usecase\InterestsUseCase.kt | |
```kt | |
package com.divadventure.domain.usecase | |
import com.divadventure.data.Repository.InterestsRepository | |
import com.divadventure.domain.models.InterestsResponse | |
import javax.inject.Inject | |
class InterestsUseCase @Inject constructor( | |
private val interestsRepository: InterestsRepository | |
) { | |
suspend fun fetchInterests(): Result<InterestsResponse> { | |
return try { | |
val interests = interestsRepository.getInterests() | |
Result.success(interests) | |
} catch (exception: Exception) { | |
Result.failure(exception) | |
} | |
} | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\domain\usecase\LocationsUseCase.kt | |
```kt | |
package com.divadventure.domain.usecase | |
import com.google.android.gms.maps.model.LatLng | |
import com.google.android.libraries.places.api.model.AutocompletePrediction | |
import com.google.android.libraries.places.api.model.Place | |
import com.google.android.libraries.places.api.model.RectangularBounds | |
import com.google.android.libraries.places.api.net.FetchPlaceRequest | |
import com.google.android.libraries.places.api.net.FindAutocompletePredictionsRequest | |
import com.google.android.libraries.places.api.net.PlacesClient | |
import kotlinx.coroutines.tasks.await | |
import timber.log.Timber | |
import javax.inject.Inject | |
class LocationsUseCase @Inject constructor(private val placesClient: PlacesClient) { | |
suspend fun predictLocations(text: String): List<AutocompletePrediction> { | |
return try { | |
val request = FindAutocompletePredictionsRequest.builder() | |
.setQuery(text) | |
.build() | |
val response = placesClient.findAutocompletePredictions(request).await() | |
response.autocompletePredictions | |
} catch (exception: Exception) { | |
Timber.e("Error fetching location predictions: $exception") | |
emptyList() // Return empty list in case of failure | |
} | |
} | |
suspend fun goLocation(placeId: String): Place? { | |
return try { | |
val placeFields = listOf(Place.Field.LAT_LNG, Place.Field.NAME, Place.Field.ADDRESS) | |
val request = FetchPlaceRequest.builder(placeId, placeFields).build() | |
val response = placesClient.fetchPlace(request).await() | |
response.place // Return the location | |
} catch (exception: Exception) { | |
Timber.e("Error fetching place details: $exception") | |
null // Return null in case of failure | |
} | |
} | |
suspend fun goLocation(location: LatLng): Place? { | |
return try { | |
// Step 1: Use findAutocompletePredictions to get a placeId from LatLng. | |
// We'll use the coordinates as the query string. | |
// Location biasing helps to narrow down results to the specific point. | |
val autocompleteRequest = FindAutocompletePredictionsRequest.builder() | |
.setQuery("${location.latitude},${location.longitude}") // Use coordinates as query | |
// Bias results to a small rectangular area around the LatLng for better accuracy. | |
// You might need to add: import com.google.android.libraries.places.api.model.RectangularBounds | |
.setLocationBias(RectangularBounds.newInstance( | |
LatLng(location.latitude - 0.001, location.longitude - 0.001), // SW corner | |
LatLng(location.latitude + 0.001, location.longitude + 0.001) // NE corner | |
)) | |
.build() | |
val predictionResponse = placesClient.findAutocompletePredictions(autocompleteRequest).await() | |
if (predictionResponse.autocompletePredictions.isNotEmpty()) { | |
// Take the first prediction, assuming it's the most relevant for the coordinates. | |
val placeId = predictionResponse.autocompletePredictions[0].placeId | |
// Step 2: Fetch place details using the obtained placeId. | |
val placeFields = listOf(Place.Field.LAT_LNG, Place.Field.NAME, Place.Field.ADDRESS) | |
val fetchPlaceRequest = FetchPlaceRequest.builder(placeId, placeFields).build() | |
val fetchPlaceResponse = placesClient.fetchPlace(fetchPlaceRequest).await() | |
fetchPlaceResponse.place | |
} else { | |
Timber.w("No place predictions found for LatLng: $location") | |
null | |
} | |
} catch (exception: Exception) { | |
Timber.e("Error in goLocation(LatLng) for $location: $exception") | |
null // Return null in case of failure | |
} | |
} | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\domain\usecase\NotificationsUseCase.kt | |
```kt | |
package com.divadventure.domain.usecase | |
class NotificationsUseCase { | |
fun sendNotification(title: String, message: String): Boolean { | |
// Logic to send a notification | |
println("Notification sent: Title: $title, Message: $message") | |
return true | |
} | |
fun scheduleNotification(title: String, message: String, delayInMillis: Long): Boolean { | |
// Logic to schedule a notification | |
println("Notification scheduled: Title: $title, Message: $message, Delay: $delayInMillis ms") | |
return true | |
} | |
fun cancelNotification(notificationId: Int): Boolean { | |
// Logic to cancel a notification | |
println("Notification with ID $notificationId cancelled") | |
return true | |
} | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\domain\usecase\RequestsUseCase.kt | |
```kt | |
package com.divadventure.domain.usecase | |
import com.divadventure.data.Repository.RequestsRepository | |
import com.divadventure.domain.models.AdventureRequestResponse | |
import com.divadventure.domain.models.AdventureRequestsResponse | |
import com.divadventure.domain.models.UsersAdventureRequestResponse | |
import jakarta.inject.Inject | |
class RequestsUseCase @Inject constructor( | |
private val requestsRepository: RequestsRepository | |
) { | |
suspend fun fetchAdventureRequests(adventureId: String): Result<AdventureRequestsResponse> { | |
return try { | |
val requests = requestsRepository.fetchAllAdventureRequests(adventureId) | |
Result.success(requests) | |
} catch (exception: Exception) { | |
Result.failure(exception) | |
} | |
} | |
suspend fun fetchUserRequests( | |
adventureId: String, | |
userId: String | |
): Result<UsersAdventureRequestResponse> { | |
return try { | |
val userDataResult = requestsRepository.fetchUserAdventureRequests(adventureId, userId) | |
Result.success(userDataResult) | |
} catch (exception: Exception) { | |
Result.failure(exception) | |
} | |
} | |
suspend fun acceptJoinRequest(adventureId: String, requestId: String): Result<com.divadventure.domain.models.AdventureRequestResponse> { | |
return try { | |
val response = requestsRepository.acceptJoinRequest(adventureId, requestId) | |
Result.success(response) | |
} catch (exception: Exception) { | |
Result.failure(exception) | |
} | |
} | |
suspend fun declineJoinRequest(adventureId: String, requestId: String): Result<com.divadventure.domain.models.AdventureRequestResponse> { | |
return try { | |
val response = requestsRepository.declineJoinRequest(adventureId, requestId) | |
Result.success(response) | |
} catch (exception: Exception) { | |
Result.failure(exception) | |
} | |
} | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\domain\usecase\TasksUseCase.kt | |
```kt | |
package com.divadventure.domain.usecase | |
class TasksUseCase { | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\domain\usecase\UsersUseCase.kt | |
```kt | |
package com.divadventure.domain.usecase | |
import com.divadventure.data.Repository.UsersRepository | |
import com.divadventure.domain.models.FriendsResponse | |
import com.divadventure.domain.models.UsersData | |
import javax.inject.Inject | |
class UsersUseCase @Inject constructor( | |
private val usersRepository: UsersRepository | |
) { | |
/** | |
* Retrieves the list of friends. | |
* - If successful, returns `Result.success` with the list of friends. | |
* - If an error occurs, returns a `Result.failure` with the exception. | |
*/ | |
suspend fun getFriends(): Result<FriendsResponse> { | |
return try { | |
val friendsResponse = usersRepository.getFriends() | |
if (friendsResponse.friends.isNullOrEmpty()) { | |
Result.failure(NoSuchElementException("No friends found.")) | |
} else { | |
Result.success(friendsResponse) | |
} | |
} catch (e: Exception) { | |
Result.failure(e) | |
} | |
} | |
suspend fun getElseFriends(profileId: String): Result<FriendsResponse> { | |
return try { | |
val friendsResponse = usersRepository.getElseFriends(profileId) | |
if (friendsResponse.friends.isNullOrEmpty()) { | |
Result.failure(NoSuchElementException("No friends found.")) | |
} else { | |
Result.success(friendsResponse) | |
} | |
} catch (e: Exception) { | |
Result.failure(e) | |
} | |
} | |
suspend fun getUserData(userId: String): Result<UsersData> { | |
return try { | |
val userData = usersRepository.getUserData(userId) | |
Result.success(userData) | |
} catch (e: Exception) { | |
Result.failure(e) | |
} | |
} | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\ui\AddShared.kt | |
```kt | |
package com.divadventure.ui | |
import androidx.compose.foundation.background | |
import androidx.compose.foundation.clickable | |
import androidx.compose.foundation.interaction.MutableInteractionSource | |
import androidx.compose.foundation.layout.Box | |
import androidx.compose.foundation.layout.Column | |
import androidx.compose.foundation.layout.PaddingValues | |
import androidx.compose.foundation.layout.Row | |
import androidx.compose.foundation.layout.fillMaxWidth | |
import androidx.compose.foundation.layout.height | |
import androidx.compose.foundation.layout.padding | |
import androidx.compose.foundation.layout.size | |
import androidx.compose.foundation.lazy.LazyColumn | |
import androidx.compose.foundation.shape.RoundedCornerShape | |
import androidx.compose.foundation.text.BasicTextField | |
import androidx.compose.material3.ButtonDefaults | |
import androidx.compose.material3.Icon | |
import androidx.compose.material3.Text | |
import androidx.compose.material3.TextButton | |
import androidx.compose.runtime.Composable | |
import androidx.compose.runtime.MutableState | |
import androidx.compose.runtime.getValue | |
import androidx.compose.runtime.mutableIntStateOf | |
import androidx.compose.runtime.remember | |
import androidx.compose.runtime.setValue | |
import androidx.compose.ui.Alignment | |
import androidx.compose.ui.Modifier | |
import androidx.compose.ui.graphics.Color | |
import androidx.compose.ui.platform.LocalDensity | |
import androidx.compose.ui.res.painterResource | |
import androidx.compose.ui.text.PlatformTextStyle | |
import androidx.compose.ui.text.TextStyle | |
import androidx.compose.ui.text.font.Font | |
import androidx.compose.ui.text.font.FontFamily | |
import androidx.compose.ui.text.font.FontWeight | |
import androidx.compose.ui.unit.dp | |
import com.divadventure.R | |
import com.divadventure.ui.screens.main.home.notifications.search.filter.SelectionImage | |
@Composable | |
fun TitleCompose( | |
text: String, isMandatory: Boolean = false | |
) { | |
Row( | |
verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding( | |
top = 20.dp, bottom = 10.dp, start = 20.dp, end = 20.dp | |
) | |
) { | |
Text( | |
modifier = Modifier.padding( | |
), text = text, style = TextStyle( | |
color = Color.Black, | |
fontSize = with(LocalDensity.current) { 13.dp.toSp() }, | |
fontFamily = FontFamily(Font(R.font.sf_pro)), | |
fontWeight = FontWeight.W600 | |
) | |
) | |
if (isMandatory) { | |
AsteriskCompose() | |
} | |
} | |
} | |
@Composable | |
fun PersonalInfoTextField( | |
title: String, | |
value: String, | |
onValueChange: (String) -> Unit, | |
modifier: Modifier = Modifier, | |
minLines: Int = 1, | |
mandatory: Boolean = false, | |
hint: String = "" | |
) { | |
Column { | |
Row( | |
verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding( | |
start = 20.dp, top = 20.dp | |
) | |
) { | |
Text( | |
modifier = Modifier, text = title, style = TextStyle( | |
color = Color.Black, | |
platformStyle = PlatformTextStyle(includeFontPadding = false), | |
fontSize = with(LocalDensity.current) { 9.dp.toSp() }, | |
fontFamily = FontFamily(Font(R.font.sf_pro)), | |
fontWeight = FontWeight.W600, | |
) | |
) | |
if (mandatory) AsteriskCompose() | |
} | |
SimpleTextField( | |
value = value, | |
onValueChange = onValueChange, | |
modifier = modifier, | |
descLines = minLines, | |
hint = hint | |
) | |
} | |
} | |
@Composable | |
fun SimpleTextField( | |
value: String, | |
onValueChange: (String) -> Unit, | |
modifier: Modifier = Modifier, | |
descLines: Int = 1, | |
hint: String = "", | |
) { | |
BasicTextField( | |
value = value, | |
minLines = descLines, | |
onValueChange = onValueChange, | |
modifier = modifier | |
.fillMaxWidth() | |
.padding(20.dp), | |
textStyle = TextStyle( | |
fontWeight = FontWeight.W400, | |
fontSize = with(LocalDensity.current) { 13.dp.toSp() }, | |
color = Color.Black | |
), | |
interactionSource = remember { MutableInteractionSource() }, | |
decorationBox = { innerTextField -> | |
Box( | |
modifier = Modifier | |
.background(color = Color.Transparent) | |
.padding(PaddingValues(0.dp)) | |
) { | |
if (value.isEmpty()) { | |
Text( | |
text = hint, style = TextStyle( | |
fontWeight = FontWeight.W400, | |
fontSize = with(LocalDensity.current) { 13.dp.toSp() }, | |
color = Color(0xFF8A8A8E) | |
) | |
) | |
} | |
innerTextField() | |
} | |
}) | |
} | |
@Composable | |
fun WhiteRoundedCornerFrame( | |
modifier: Modifier = Modifier, content: @Composable () -> Unit | |
) { | |
Box( | |
contentAlignment = Alignment.Center, | |
modifier = modifier | |
.padding(horizontal = 20.dp) | |
.fillMaxWidth() | |
.background(color = Color.White, shape = RoundedCornerShape(8.dp)) | |
) { | |
content() | |
} | |
} | |
@Composable | |
fun ItemTextClickIcon( | |
title: String, | |
value: String? = null, | |
isMandatory: Boolean = false, | |
onClick: () -> Unit = {}, | |
content: @Composable () -> Unit | |
) { | |
Row( | |
verticalAlignment = Alignment.CenterVertically, | |
modifier = Modifier | |
.clickable( | |
enabled = true, onClick = onClick | |
) | |
.padding(start = 20.dp, top = 20.dp, bottom = 20.dp, end = 20.dp) | |
) { | |
Row( | |
modifier = Modifier | |
.weight(1f) | |
.align(Alignment.CenterVertically), | |
verticalAlignment = Alignment.CenterVertically | |
) { | |
Text( | |
modifier = Modifier.padding(end = 4.dp), // Add some padding if value is present | |
text = title, | |
style = TextStyle( | |
color = Color.Black, | |
fontSize = with(LocalDensity.current) { 13.dp.toSp() }, | |
fontFamily = FontFamily(Font(R.font.sf_pro)), | |
fontWeight = FontWeight.W600 | |
) | |
) | |
if (isMandatory) { | |
AsteriskCompose() | |
} | |
if (!value.isNullOrEmpty()) { | |
Text( | |
text = value, | |
style = TextStyle( | |
color = Color.Gray, // Different color for the value | |
fontSize = with(LocalDensity.current) { 13.dp.toSp() }, | |
fontFamily = FontFamily(Font(R.font.sf_pro)), | |
fontWeight = FontWeight.W400 | |
), | |
modifier = Modifier.padding(start = 8.dp) // Space between title and value | |
) | |
} | |
} | |
Box( | |
contentAlignment = Alignment.Center, | |
modifier = Modifier.align(Alignment.CenterVertically) | |
) { | |
content() | |
} | |
} | |
} | |
@Composable | |
fun MandatoryInterestsComposable( | |
title: String, | |
value: String? = null, | |
onClick: () -> Unit = {} | |
) { | |
ItemTextClickIcon( | |
isMandatory = true, | |
title = title, | |
value = value, // Pass the value to ItemTextClickIcon | |
content = { | |
Row( | |
modifier = Modifier | |
.padding() | |
.clickable(onClick = onClick), | |
verticalAlignment = Alignment.CenterVertically | |
) { | |
// The static "Label" Text is removed as ItemTextClickIcon now handles the value display. | |
// If no value is provided to ItemTextClickIcon, nothing will be shown there, | |
// which is acceptable. If a placeholder is needed when value is null/empty, | |
// ItemTextClickIcon's logic would need to be adjusted, or a default value passed here. | |
// For now, keeping it clean. | |
Icon( | |
modifier = Modifier.padding(start = 10.dp), // Keep padding for the icon | |
painter = painterResource(id = R.drawable.right_chevron), | |
tint = Color(0x3C3C4399).copy(alpha = 0.6f), | |
contentDescription = "" | |
) | |
} | |
}) | |
} | |
@Composable | |
fun SelectionList(defaultIndex: Int = 2, onSelectItem: (Int) -> Unit) { | |
// State to track the selected index | |
var selectedIndex by remember { mutableIntStateOf(defaultIndex) } // Default selected item (index 2) | |
val items = listOf( | |
SelectionItem("Invite Only", isSelected = false), | |
SelectionItem("Friends", isSelected = false), | |
SelectionItem("Public", isSelected = false), | |
) | |
LazyColumn( | |
userScrollEnabled = false, modifier = Modifier.height((items.size * 65).dp) | |
) { | |
items(items.size) { index -> | |
SelectionRow( | |
text = items[index].text, | |
isSelected = index == selectedIndex, // Only the selected index is true | |
selectionImage = { isSelected -> SelectionImage(isSelected) }, | |
onClick = { | |
// Update selected index | |
selectedIndex = index | |
onSelectItem.invoke(index) | |
}) | |
SortDivider() // Add divider between items | |
} | |
} | |
} | |
// Define the data model for the list items | |
data class SelectionItem( | |
val text: String, val isSelected: Boolean | |
) | |
@Composable | |
fun AsteriskCompose() { | |
Box(modifier = Modifier.padding(5.dp)) { | |
Icon( | |
modifier = Modifier | |
.size(7.5.dp) | |
.align(Alignment.Center), | |
painter = painterResource(id = R.drawable.ic_asterisk), | |
contentDescription = "Asterisk Icon", | |
tint = Color(0xFFFF2D55) | |
) | |
} | |
} | |
@Composable | |
fun ChangerButton( | |
modifier: Modifier = Modifier, | |
isActive: MutableState<Boolean>, | |
text: String, | |
deActiveTextColor: Color, | |
deActiveButtonColor: Color, | |
activeTextColor: Color, | |
activeButtonColor: Color, | |
onClick: () -> Unit | |
) { | |
TextButton( | |
shape = RoundedCornerShape(4.dp), | |
modifier = modifier | |
.fillMaxWidth(), | |
colors = ButtonDefaults.buttonColors( | |
containerColor = if (isActive.value) activeButtonColor else deActiveButtonColor | |
), | |
onClick = onClick) { | |
Box( | |
modifier = Modifier.padding(vertical = 10.dp), | |
contentAlignment = Alignment.Center | |
) { | |
Text( | |
modifier = Modifier.align(Alignment.Center), | |
text = text, | |
style = TextStyle( | |
fontSize = with(LocalDensity.current) { 13.dp.toSp() }, | |
color = if (isActive.value) activeTextColor else deActiveTextColor | |
) | |
) | |
} | |
} | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\ui\AuthShared.kt | |
```kt | |
package com.divadventure.ui | |
import androidx.compose.animation.AnimatedVisibility | |
import androidx.compose.animation.core.tween | |
import androidx.compose.animation.fadeIn | |
import androidx.compose.animation.fadeOut | |
import androidx.compose.foundation.Image | |
import androidx.compose.foundation.background | |
import androidx.compose.foundation.layout.Box | |
import androidx.compose.foundation.layout.Column | |
import androidx.compose.foundation.layout.Row | |
import androidx.compose.foundation.layout.fillMaxWidth | |
import androidx.compose.foundation.layout.height | |
import androidx.compose.foundation.layout.padding | |
import androidx.compose.foundation.layout.size | |
import androidx.compose.foundation.layout.statusBarsPadding | |
import androidx.compose.foundation.shape.RoundedCornerShape | |
import androidx.compose.foundation.text.BasicTextField | |
import androidx.compose.foundation.text.KeyboardOptions | |
import androidx.compose.material3.Text | |
import androidx.compose.runtime.Composable | |
import androidx.compose.runtime.MutableState | |
import androidx.compose.ui.Alignment | |
import androidx.compose.ui.Modifier | |
import androidx.compose.ui.draw.clip | |
import androidx.compose.ui.graphics.Color | |
import androidx.compose.ui.graphics.ColorFilter | |
import androidx.compose.ui.graphics.lerp | |
import androidx.compose.ui.graphics.vector.ImageVector | |
import androidx.compose.ui.res.vectorResource | |
import androidx.compose.ui.text.AnnotatedString | |
import androidx.compose.ui.text.TextStyle | |
import androidx.compose.ui.text.font.Font | |
import androidx.compose.ui.text.font.FontFamily | |
import androidx.compose.ui.text.font.FontWeight | |
import androidx.compose.ui.text.input.KeyboardType | |
import androidx.compose.ui.text.input.OffsetMapping | |
import androidx.compose.ui.text.input.PasswordVisualTransformation | |
import androidx.compose.ui.text.input.TransformedText | |
import androidx.compose.ui.text.input.VisualTransformation | |
import androidx.compose.ui.text.style.TextAlign | |
import androidx.compose.ui.unit.Dp | |
import androidx.compose.ui.unit.dp | |
import androidx.compose.ui.unit.sp | |
import androidx.compose.ui.zIndex | |
import com.divadventure.R | |
@Composable | |
fun AuthTextField( | |
modifier: Modifier, | |
hint: String, | |
text: MutableState<String>, | |
onValueChange: (String) -> Unit, | |
explain: String, | |
essential: Boolean = false, | |
isPassword: Boolean = false, | |
isEmail: Boolean = false, | |
isNormalText: Boolean = false, | |
enabled: Boolean = true | |
) { | |
Column( | |
modifier = modifier | |
) { | |
if (hint.isNotEmpty()) { | |
Row( | |
Modifier.padding(20.dp, 0.dp, 0.dp, 10.dp), | |
verticalAlignment = Alignment.CenterVertically | |
) { | |
Text(hint, color = Color(0xff1C1C1E)) | |
if (essential) { | |
// Text("*", style = TextStyle(color = Color.Red)) | |
Image( | |
modifier = Modifier.size(7.5.dp), | |
contentDescription = "asterisk_essential_field", | |
imageVector = ImageVector.vectorResource( | |
R.drawable.ic_asterisk | |
), | |
colorFilter = ColorFilter.tint(Color(0xffFF2D55)) | |
) | |
} | |
} | |
} | |
val originalColor = Color(0x3C3C4399) | |
val darkenPercentage = 0.4f // 20% darker | |
val darkerColor = lerp(originalColor, Color.Black, darkenPercentage) | |
Box(modifier = Modifier.padding(20.dp, 0.dp, 0.dp, 0.dp)) { | |
if (text.value.isEmpty()) { | |
Text( | |
text = explain, color = darkerColor, style = TextStyle( | |
fontSize = 14.sp, fontFamily = FontFamily(Font(R.font.sf_pro)) | |
) | |
) | |
} | |
BasicTextField( | |
singleLine = true, | |
modifier = Modifier | |
.fillMaxWidth() | |
.padding(0.dp, 0.dp, 0.dp, 10.dp), | |
value = text.value, | |
enabled = enabled, | |
onValueChange = { | |
text.value = it | |
onValueChange(it) | |
}, | |
textStyle = TextStyle( | |
fontFamily = FontFamily(Font(R.font.sf_pro)), | |
color = Color(0xff1C1C1E), | |
fontSize = 17.sp | |
), | |
keyboardOptions = when { | |
isPassword -> { | |
KeyboardOptions(keyboardType = KeyboardType.Password) | |
} | |
isEmail -> { | |
KeyboardOptions(keyboardType = KeyboardType.Email) | |
} | |
else -> { | |
KeyboardOptions(keyboardType = KeyboardType.Text) | |
} | |
}, | |
visualTransformation = when { | |
isPassword -> { | |
PasswordVisualTransformation() | |
} | |
else -> { | |
VisualTransformation.None | |
} | |
}, | |
) | |
} | |
Image( | |
modifier = Modifier | |
.fillMaxWidth() | |
.padding(20.dp, 0.dp, 0.dp, 0.dp) | |
.height(1.dp) | |
.background(Color(0xffb9b9bb)), | |
contentDescription = "horizontal_divider", | |
imageVector = ImageVector.vectorResource(id = R.drawable.horiontal_line), | |
) | |
} | |
} | |
class StarVisualTransformation : VisualTransformation { | |
override fun filter(text: AnnotatedString): TransformedText { | |
return TransformedText( | |
AnnotatedString("*".repeat(text.length)), OffsetMapping.Identity | |
) | |
} | |
} | |
@Composable | |
fun TopSnackBar( | |
paddingTop: Dp, | |
title: String, | |
message: String, | |
show: Boolean, | |
onDismiss: () -> Unit | |
) { | |
AnimatedVisibility( | |
modifier = Modifier.zIndex(10f), | |
visible = show, | |
enter = fadeIn(animationSpec = tween(durationMillis = 500)), | |
exit = fadeOut(animationSpec = tween(durationMillis = 500)), | |
) { | |
Box( | |
modifier = Modifier | |
.fillMaxWidth() | |
.padding( | |
16.dp, | |
16.dp, | |
16.dp, | |
16.dp | |
) | |
.clip(RoundedCornerShape(23.dp)) | |
.background(Color(0xffFAFAFA)) | |
.statusBarsPadding() | |
.padding(16.dp) | |
.zIndex(10f), | |
contentAlignment = Alignment.Center | |
) { | |
Column(modifier = Modifier.padding(5.dp)) { | |
Row { | |
Image( | |
modifier = Modifier.size(15.dp), | |
contentDescription = "app_logo", | |
imageVector = ImageVector.vectorResource(id = R.drawable.logo) | |
) | |
Text( | |
modifier = Modifier | |
.padding(10.dp, 0.dp) | |
.weight(1f, false) | |
.fillMaxWidth(), | |
text = "DivAdventure", | |
style = TextStyle( | |
fontSize = 14.sp, | |
textAlign = TextAlign.Left, | |
fontFamily = FontFamily(Font(R.font.sf_pro)), | |
color = Color.Black | |
) | |
) | |
Text( | |
text = "now", style = TextStyle( | |
fontSize = 13.sp, | |
color = Color(0xff898989), | |
fontFamily = FontFamily(Font(R.font.sf_pro)), | |
) | |
) | |
} | |
Text( | |
modifier = Modifier.padding(0.dp, 10.dp, 10.dp, 5.dp), | |
text = title, style = TextStyle( | |
fontFamily = FontFamily(Font(R.font.sf_pro)), | |
color = Color.Black, | |
fontWeight = FontWeight.Bold, | |
fontSize = 16.sp | |
) | |
) | |
Text( | |
// modifier = Modifier.padding(0.dp, 0.dp, 0.dp, 0.dp), | |
style = TextStyle( | |
color = Color.Black, | |
fontFamily = FontFamily( | |
Font(R.font.sf_pro) | |
), fontSize = 13.sp | |
), | |
text = message, | |
fontFamily = FontFamily(Font(R.font.sf_pro)) | |
) | |
} | |
} | |
} | |
} | |
val CARD_HEIGHT = 55.dp | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\ui\HomeShared.kt | |
```kt | |
package com.divadventure.ui | |
import androidx.compose.animation.AnimatedVisibility | |
import androidx.compose.animation.core.animateDpAsState | |
import androidx.compose.animation.core.tween | |
import androidx.compose.animation.fadeIn | |
import androidx.compose.animation.fadeOut | |
import androidx.compose.animation.scaleIn | |
import androidx.compose.animation.scaleOut | |
import androidx.compose.foundation.Image | |
import androidx.compose.foundation.background | |
import androidx.compose.foundation.clickable | |
import androidx.compose.foundation.interaction.MutableInteractionSource | |
import androidx.compose.foundation.layout.Arrangement | |
import androidx.compose.foundation.layout.Box | |
import androidx.compose.foundation.layout.Column | |
import androidx.compose.foundation.layout.PaddingValues | |
import androidx.compose.foundation.layout.Row | |
import androidx.compose.foundation.layout.Spacer | |
import androidx.compose.foundation.layout.aspectRatio | |
import androidx.compose.foundation.layout.fillMaxHeight | |
import androidx.compose.foundation.layout.fillMaxSize | |
import androidx.compose.foundation.layout.fillMaxWidth | |
import androidx.compose.foundation.layout.height | |
import androidx.compose.foundation.layout.offset | |
import androidx.compose.foundation.layout.padding | |
import androidx.compose.foundation.layout.size | |
import androidx.compose.foundation.layout.width | |
import androidx.compose.foundation.layout.wrapContentHeight | |
import androidx.compose.foundation.layout.wrapContentSize | |
import androidx.compose.foundation.layout.wrapContentWidth | |
import androidx.compose.foundation.lazy.grid.GridCells | |
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid | |
import androidx.compose.foundation.lazy.grid.itemsIndexed | |
import androidx.compose.foundation.shape.CircleShape | |
import androidx.compose.foundation.shape.RoundedCornerShape | |
import androidx.compose.foundation.text.BasicTextField | |
import androidx.compose.foundation.text.KeyboardOptions | |
import androidx.compose.material3.ButtonDefaults | |
import androidx.compose.material3.Card | |
import androidx.compose.material3.CardDefaults | |
import androidx.compose.material3.ElevatedButton | |
import androidx.compose.material3.ExperimentalMaterial3Api | |
import androidx.compose.material3.HorizontalDivider | |
import androidx.compose.material3.Icon | |
import androidx.compose.material3.IconButton | |
import androidx.compose.material3.LocalTextStyle | |
import androidx.compose.material3.Surface | |
import androidx.compose.material3.Text | |
import androidx.compose.material3.TextButton | |
import androidx.compose.material3.TextField | |
import androidx.compose.material3.TextFieldDefaults | |
import androidx.compose.runtime.Composable | |
import androidx.compose.runtime.MutableState | |
import androidx.compose.runtime.getValue | |
import androidx.compose.runtime.mutableStateOf | |
import androidx.compose.runtime.remember | |
import androidx.compose.runtime.setValue | |
import androidx.compose.ui.Alignment | |
import androidx.compose.ui.Modifier | |
import androidx.compose.ui.draw.clip | |
import androidx.compose.ui.geometry.Offset | |
import androidx.compose.ui.geometry.Size | |
import androidx.compose.ui.graphics.Color | |
import androidx.compose.ui.graphics.Outline | |
import androidx.compose.ui.graphics.Path | |
import androidx.compose.ui.graphics.Shape | |
import androidx.compose.ui.graphics.vector.ImageVector | |
import androidx.compose.ui.layout.ContentScale | |
import androidx.compose.ui.layout.onGloballyPositioned | |
import androidx.compose.ui.platform.LocalConfiguration | |
import androidx.compose.ui.platform.LocalDensity | |
import androidx.compose.ui.res.painterResource | |
import androidx.compose.ui.res.vectorResource | |
import androidx.compose.ui.text.TextStyle | |
import androidx.compose.ui.text.font.Font | |
import androidx.compose.ui.text.font.FontFamily | |
import androidx.compose.ui.text.font.FontWeight | |
import androidx.compose.ui.text.input.KeyboardType | |
import androidx.compose.ui.text.input.VisualTransformation | |
import androidx.compose.ui.text.style.TextAlign | |
import androidx.compose.ui.text.style.TextOverflow | |
import androidx.compose.ui.unit.Density | |
import androidx.compose.ui.unit.Dp | |
import androidx.compose.ui.unit.IntOffset | |
import androidx.compose.ui.unit.LayoutDirection | |
import androidx.compose.ui.unit.dp | |
import androidx.compose.ui.unit.sp | |
import androidx.compose.ui.window.Dialog | |
import androidx.compose.ui.window.DialogProperties | |
import coil.compose.rememberAsyncImagePainter | |
import com.divadventure.R | |
import com.divadventure.domain.models.Friend | |
import com.divadventure.ui.screens.main.home.GuideItem | |
import com.divadventure.ui.screens.main.home.getScreenWidthInDp | |
import com.kizitonwose.calendar.compose.CalendarState | |
import com.kizitonwose.calendar.compose.HorizontalCalendar | |
import com.kizitonwose.calendar.compose.rememberCalendarState | |
import com.kizitonwose.calendar.core.CalendarDay | |
import com.kizitonwose.calendar.core.yearMonth | |
import kotlinx.coroutines.CoroutineScope | |
import kotlinx.coroutines.Dispatchers | |
import kotlinx.coroutines.launch | |
import java.time.LocalTime | |
import java.time.YearMonth | |
import kotlin.time.ExperimentalTime | |
/** | |
* این فایل شامل کامپوزها و UI پراستفاده در قسمت Home است | |
* */ | |
@Composable | |
fun SlidingDualToggleButton( | |
padding: Dp, options: List<String>, onToggle: (Int) -> Unit | |
) { | |
var selectedIndex by remember { mutableStateOf(0) } | |
val screenWidthDp = LocalConfiguration.current.screenWidthDp.dp - (padding.value * 2).dp | |
val itemWidth = screenWidthDp / options.size | |
val density = LocalDensity.current.density | |
val offset = animateDpAsState( | |
targetValue = (selectedIndex * itemWidth.value).coerceIn( | |
0f, screenWidthDp.value - itemWidth.value | |
).dp | |
) | |
Row( | |
modifier = Modifier | |
.padding(padding) | |
.fillMaxWidth() // Fill the parent's width | |
.height(48.dp) | |
.background(Color(0xFFe5e5ea), RoundedCornerShape(8.dp)) | |
) { | |
Box( | |
modifier = Modifier | |
.fillMaxWidth() | |
.height(44.dp) | |
.padding(2.dp) | |
.background(Color(0xFFe5e5ea), RoundedCornerShape(8.dp)), | |
contentAlignment = Alignment.CenterStart | |
) { | |
Row { | |
options.forEachIndexed { index, option -> | |
Box( | |
modifier = Modifier.weight(1f, true) | |
) { | |
Text( | |
fontSize = with(LocalDensity.current) { 12.dp.toSp() }, | |
modifier = Modifier.align(Alignment.Center), | |
text = option, | |
color = Color(0xff1C1C1E) | |
) | |
} | |
} | |
} | |
Card( | |
elevation = CardDefaults.cardElevation(defaultElevation = 6.dp), | |
shape = RoundedCornerShape(8.dp), | |
colors = CardDefaults.cardColors(Color.White), | |
modifier = Modifier | |
.width(itemWidth) // Use dynamic item width | |
.fillMaxHeight() | |
.offset { IntOffset(offset.value.roundToPx(), 0) } | |
.padding(end = 5.dp)) { | |
Box( | |
modifier = Modifier.fillMaxSize(), | |
contentAlignment = Alignment.Center // Centers content both vertically and horizontally | |
) { | |
Text( | |
modifier = Modifier.align( | |
Alignment.Center | |
), | |
text = options[selectedIndex], | |
color = Color(0xFF1C1C1E), | |
fontSize = with(LocalDensity.current) { 12.dp.toSp() }, | |
textAlign = TextAlign.Center // Center text horizontally | |
) | |
} | |
} | |
Box( | |
modifier = Modifier | |
.fillMaxWidth() | |
.fillMaxHeight() | |
.clickable( | |
interactionSource = remember { MutableInteractionSource() }, | |
indication = null | |
) { | |
selectedIndex = (selectedIndex + 1) % options.size | |
onToggle(selectedIndex) | |
}) | |
} | |
} | |
} | |
class TooltipShape( | |
private val arrowDp: Dp = 20.dp, | |
private val cornerRadius: Dp = 8.dp, | |
private val offsetX: Dp = 0.dp, | |
private val offsetY: Dp = 0.dp | |
) : Shape { | |
override fun createOutline( | |
size: Size, layoutDirection: LayoutDirection, density: Density | |
): Outline { | |
val arrowPx = with(density) { arrowDp.toPx() } | |
val cornerRadiusPx = with(density) { cornerRadius.toPx() } | |
val path = Path().apply { | |
// Start from top-left rounded corner | |
moveTo(cornerRadiusPx, arrowPx) | |
quadraticBezierTo( | |
0f, arrowPx, 0f, arrowPx + cornerRadiusPx | |
) // Top-left corner curve | |
lineTo(0f, size.height - cornerRadiusPx) // Left edge | |
quadraticBezierTo( | |
0f, size.height, cornerRadiusPx, size.height | |
) // Bottom-left corner curve | |
lineTo(size.width - cornerRadiusPx, size.height) // Bottom edge | |
quadraticBezierTo( | |
size.width, size.height, size.width, size.height - cornerRadiusPx | |
) // Bottom-right corner curve | |
lineTo(size.width, arrowPx + cornerRadiusPx) // Right edge | |
quadraticBezierTo( | |
size.width, arrowPx, size.width - cornerRadiusPx, arrowPx | |
) // Top-right corner curve | |
lineTo(size.width / 2 + arrowPx, arrowPx) // Triangle base right point | |
lineTo(size.width / 2, 0f) // Triangle tip | |
lineTo(size.width / 2 - arrowPx, arrowPx) // Triangle base left point | |
close() // Complete the shape | |
} | |
path.translate(Offset(offsetX.value, offsetY.value)) | |
return Outline.Generic(path) | |
} | |
} | |
val calendarCirclesSize = 25.dp | |
val eventsColors = listOf( | |
"PastAdventures" to Color(0xFFD7D7DF), | |
"ActiveAdventures" to Color(0xFFC5EACF), | |
"UpcomingAdventures" to Color(0xFFF5E2C6), | |
"CurrentDay" to Color(0xFF30D158), | |
"SelectedDay" to Color(0xFF5856D6) | |
) | |
@Composable | |
fun AdventureCalendarItem( | |
modifier: Modifier = Modifier, | |
textColor: Color, | |
text: String, | |
backgroundColor: Color, | |
count: Int = 0, | |
countColor: Color? = null, | |
isSelected: Boolean = false, | |
onSelect: () -> (Unit) = {} | |
) { | |
var textWidth by remember { mutableStateOf(0.dp) } | |
var textHeight by remember { mutableStateOf(0.dp) } | |
val density = LocalDensity.current.density | |
Column( | |
modifier = modifier.clickable( | |
interactionSource = remember { MutableInteractionSource() }, | |
indication = null | |
) { | |
onSelect() | |
}, | |
) { | |
Box( | |
contentAlignment = Alignment.Center, | |
modifier = Modifier | |
.size(calendarCirclesSize) | |
.clip(CircleShape) | |
.background(backgroundColor) | |
.onGloballyPositioned { layoutc -> | |
textWidth = with(density) { layoutc.size.width / density }.dp | |
textHeight = with(density) { layoutc.size.height.toFloat() / density }.dp | |
}) { | |
Text( | |
modifier = Modifier | |
.align(Alignment.Center) | |
.padding(0.dp), | |
text = text, | |
style = TextStyle( | |
color = textColor, | |
textAlign = TextAlign.Center, | |
fontFamily = FontFamily(Font(R.font.sf_pro)), | |
fontSize = with(LocalDensity.current) { 14.dp.toSp() }, | |
fontWeight = FontWeight.SemiBold, | |
background = backgroundColor | |
) | |
) | |
} | |
Text( | |
modifier = Modifier | |
.align(Alignment.CenterHorizontally) | |
.padding(top = 5.dp), | |
text = if (count == 0) "" else count.toString(), | |
style = TextStyle( | |
color = countColor ?: textColor, | |
textAlign = TextAlign.Center, | |
fontFamily = FontFamily(Font(R.font.sf_pro)), | |
fontSize = with(LocalDensity.current) { 10.dp.toSp() }, | |
fontWeight = FontWeight.W200, | |
) | |
) | |
} | |
} | |
@Composable | |
fun CalendarGuideCompose( | |
tooltipVisible: MutableState<Boolean>, absoluteX: Dp, absoluteY: Dp | |
) { | |
if (tooltipVisible.value) { | |
Dialog( | |
properties = DialogProperties( | |
dismissOnBackPress = true, | |
usePlatformDefaultWidth = false, | |
dismissOnClickOutside = true, | |
decorFitsSystemWindows = true | |
), | |
//shape = TooltipShape(4.dp), | |
onDismissRequest = { tooltipVisible.value = false }, | |
// Align tooltip to the left-most edge | |
) { | |
Box( | |
modifier = Modifier | |
.fillMaxSize() | |
.clickable { | |
tooltipVisible.value = false | |
}) { | |
Card( | |
modifier = Modifier | |
.fillMaxWidth(0.8f) | |
.padding(20.dp) | |
.offset(x = 0.dp, y = absoluteY), | |
shape = TooltipShape(10.dp), | |
colors = CardDefaults.cardColors( | |
containerColor = Color(0xFFF2F2F2) | |
) | |
) { | |
Box( | |
modifier = Modifier.padding(10.dp) | |
) { | |
Column( | |
modifier = Modifier.padding(20.dp), | |
verticalArrangement = Arrangement.spacedBy(8.dp) // Add vertical offset of 8.dp between items | |
) { | |
Text( | |
modifier = Modifier | |
.fillMaxWidth() | |
.padding(10.dp), | |
text = "Calendar Guide", | |
style = TextStyle( | |
fontWeight = FontWeight.SemiBold, | |
fontSize = with(LocalDensity.current) { 20.dp.toSp() }, | |
fontFamily = FontFamily(Font(R.font.sf_pro)), | |
textAlign = TextAlign.Center, | |
color = Color.Black, | |
) | |
) | |
GuideItem( | |
"7", | |
Color(0xFF1C1C1E), | |
eventsColors.first().second, | |
secondText = "Past Adventures " | |
) | |
GuideItem( | |
"12", | |
Color(0xFF1C1C1E), | |
eventsColors[1].second, | |
"My Active Adventures " | |
) | |
GuideItem( | |
"15", | |
Color(0xFF1C1C1E), | |
eventsColors[2].second, | |
"My Upcoming Adventures " | |
) | |
GuideItem( | |
"17", | |
eventsColors[3].second, | |
Color.Transparent, | |
"Number of Adventures each day", | |
3 | |
) | |
GuideItem("24", Color.White, eventsColors[3].second, "Current Day ") | |
GuideItem("22", Color.White, eventsColors[4].second, "Selected Day ") | |
} | |
} | |
} | |
} | |
} | |
} | |
} | |
@Composable | |
fun BackComposeMoreButton( | |
backModifier: Modifier = Modifier, | |
onBack: () -> Unit, | |
moreModifier: Modifier = Modifier, | |
onMore: () -> Unit | |
) { | |
Row( | |
modifier = Modifier.fillMaxWidth() | |
) { | |
IconButton( | |
modifier = backModifier | |
.wrapContentHeight() | |
.padding(horizontal = 10.dp), onClick = { | |
onBack() | |
}) { | |
Icon( | |
modifier = backModifier.size(20.dp), | |
tint = Color(0xff1C1C1E), | |
imageVector = ImageVector.vectorResource(R.drawable.left_chevron), | |
contentDescription = "Notification Icon" | |
) | |
} | |
Spacer(modifier = Modifier.weight(1f, true)) | |
IconButton( | |
modifier = Modifier.padding(horizontal = 10.dp), onClick = { onMore() }) { | |
Icon( | |
modifier = Modifier | |
.wrapContentSize() | |
.padding(horizontal = 0.dp), | |
tint = Color(0xff1C1C1E), | |
imageVector = ImageVector.vectorResource(R.drawable.ic_more), | |
contentDescription = "more Icon" | |
) | |
} | |
} | |
} | |
@Composable | |
fun BackCompose(text: String, modifier: Modifier = Modifier, onBack: () -> Unit) { | |
Surface( | |
color = Color.White | |
) { | |
Row( | |
verticalAlignment = Alignment.CenterVertically, | |
modifier = modifier | |
.background(color = Color.White) | |
.padding(10.dp) | |
.fillMaxHeight(0.05f) | |
) { | |
IconButton( | |
modifier = modifier.wrapContentHeight(), onClick = { | |
onBack() | |
}) { | |
Icon( | |
modifier = modifier.size(20.dp), | |
tint = Color(0xff1C1C1E), | |
imageVector = ImageVector.vectorResource(R.drawable.left_chevron), | |
contentDescription = "Notification Icon" | |
) | |
} | |
Text( | |
text = text, | |
modifier = modifier | |
.fillMaxWidth() | |
.align(Alignment.CenterVertically), | |
style = TextStyle( | |
color = Color(0xff1C1C1E), | |
fontWeight = FontWeight.SemiBold, | |
fontFamily = FontFamily(Font(R.font.sf_pro)), | |
fontSize = with(LocalDensity.current) { 20.dp.toSp() }) | |
) | |
} | |
} | |
} | |
@Composable | |
fun ApplyButton(text: String = "apply", onClick: () -> Unit) { | |
ElevatedButton( | |
modifier = Modifier | |
.padding(40.dp) | |
.fillMaxWidth(), | |
shape = RoundedCornerShape(4.dp), | |
colors = ButtonDefaults.elevatedButtonColors( | |
containerColor = Color(0xFF30D158), contentColor = Color.White | |
), | |
onClick = { | |
onClick() | |
}) { | |
Text( | |
modifier = Modifier.padding(10.dp), text = text, color = Color.White, style = TextStyle( | |
fontFamily = FontFamily(Font(R.font.sf_pro)), | |
fontSize = with(LocalDensity.current) { 20.dp.toSp() }) | |
) | |
} | |
} | |
@Composable | |
fun SearchField( | |
queryText: String, onQueryChanged: (String) -> Unit | |
) { | |
var clearQuery by remember { mutableStateOf(false) } | |
TextField( | |
shape = RoundedCornerShape(8.dp), | |
colors = TextFieldDefaults.colors( | |
unfocusedIndicatorColor = Color.Transparent, | |
focusedIndicatorColor = Color.Transparent, | |
errorContainerColor = Color(0xFFF2F2F7), | |
focusedContainerColor = Color(0xFFF2F2F7), | |
disabledContainerColor = Color(0xFFF2F2F7), | |
unfocusedContainerColor = Color(0xFFF2F2F7) | |
), | |
textStyle = TextStyle( | |
color = Color(0xFF1C1C1E), fontFamily = FontFamily(Font(R.font.sf_pro)) | |
), | |
value = queryText, | |
onValueChange = onQueryChanged, | |
placeholder = { | |
Text( | |
text = "Search", style = TextStyle( | |
color = Color(0xFF848484), | |
fontSize = with(LocalDensity.current) { 14.dp.toSp() }) | |
) | |
}, | |
modifier = Modifier | |
.padding(start = 20.dp, end = 20.dp) | |
.fillMaxWidth(), | |
singleLine = true, | |
leadingIcon = { | |
Image( | |
painter = painterResource(id = R.drawable.ic_search_small), | |
contentDescription = "Search Icon" | |
) | |
}, | |
trailingIcon = { | |
AnimatedVisibility( | |
visible = queryText.isNotEmpty(), | |
enter = fadeIn() + scaleIn(), | |
exit = fadeOut() + scaleOut() | |
) { | |
IconButton(onClick = { | |
onQueryChanged("") | |
}) { | |
Image( | |
painter = painterResource(id = R.drawable.ic_clear), | |
contentDescription = "Search Icon" | |
) | |
} | |
} | |
}) | |
} | |
@Composable | |
fun SelectionRow( | |
text: String, | |
isSelected: Boolean, | |
selectionImage: @Composable (Boolean) -> Unit, | |
onClick: () -> Unit | |
) { | |
Row( | |
verticalAlignment = Alignment.CenterVertically, | |
modifier = Modifier | |
.fillMaxWidth() | |
.clickable(onClick = onClick) // Handle row click | |
.padding(horizontal = 16.dp, vertical = 12.dp) | |
) { | |
selectionImage(isSelected)/*SelectionImage(isSelected = isSelected)*/ // Show tick icon only if selected | |
SimpleOption(text = text) | |
} | |
} | |
@Composable | |
fun HeaderWithCloseButton( | |
modifier: Modifier = Modifier, title: String, onCloseClick: () -> Unit | |
) { | |
Box( | |
modifier = modifier | |
.background(Color.White) | |
.fillMaxWidth() | |
.padding(10.dp) | |
) { | |
// Centered Title Text | |
Text( | |
modifier = Modifier.align(Alignment.Center), text = title, style = TextStyle( | |
color = Color.Black, fontSize = with(LocalDensity.current) { 20.dp.toSp() }) | |
) | |
// Close Button aligned to the End | |
Text( | |
modifier = Modifier | |
.align(Alignment.CenterEnd) | |
.clickable { onCloseClick() } | |
.padding(10.dp), text = "Close", style = TextStyle( | |
color = Color(0xFF007AFF), fontSize = with(LocalDensity.current) { 14.dp.toSp() }) | |
) | |
} | |
} | |
@Composable | |
fun SortDivider() { | |
HorizontalDivider( | |
modifier = Modifier | |
.fillMaxWidth() | |
.height(1.dp) | |
.padding(start = 60.dp), | |
color = Color(0x3C3C435C).copy(alpha = 0.36f) | |
) | |
} | |
@Composable | |
fun SimpleOption(text: String) { | |
Text( | |
text = text, style = TextStyle( | |
fontSize = with(LocalDensity.current) { 14.dp.toSp() }, | |
color = Color.Black, | |
fontFamily = FontFamily(Font(R.font.sf_pro)) | |
), modifier = Modifier.padding(start = 8.dp) // Add spacing between icon and text | |
) | |
} | |
@Composable | |
fun SimpleSelectionImage(isSelected: Boolean) { | |
Box( | |
modifier = Modifier.size(40.dp), contentAlignment = Alignment.Center | |
) { | |
// Show AnimatedVisibility with smooth animation when selected | |
AnimatedVisibility( | |
visible = isSelected, | |
enter = scaleIn(animationSpec = tween(durationMillis = 300)) + fadeIn( | |
animationSpec = tween( | |
durationMillis = 300 | |
) | |
), // Smooth scale and fade-in animation | |
exit = scaleOut(animationSpec = tween(durationMillis = 200)) + fadeOut( | |
animationSpec = tween( | |
durationMillis = 200 | |
) | |
) // Smooth scale and fade-out animation | |
) { | |
Image( | |
painter = painterResource(id = R.drawable.ic_tick), | |
contentDescription = "Selected Sort", | |
modifier = Modifier.align(Alignment.Center) | |
) | |
} | |
} | |
} | |
@OptIn(ExperimentalTime::class) | |
@Composable | |
fun SelectDate( | |
onDismissRequest: () -> Unit, | |
defaultSelectedDay: CalendarDay? = null, | |
useClock: Boolean = false, | |
onSelectDate: (CalendarDay?, LocalTime?, String) -> Unit, | |
) { | |
val calendarState = rememberCalendarState( | |
firstVisibleMonth = YearMonth.now(), | |
startMonth = YearMonth.now().minusYears(50), | |
endMonth = YearMonth.now().plusMonths(50) | |
) | |
val currentMonth = | |
calendarState.layoutInfo.visibleMonthsInfo.maxByOrNull { it.size }?.month?.yearMonth | |
?: YearMonth.now() | |
var selectedDay by remember { mutableStateOf<CalendarDay?>(defaultSelectedDay) } | |
var selectedClock by remember { mutableStateOf(if (useClock) LocalTime.now() else null) } | |
var amPm by remember { mutableStateOf("PM") } | |
Dialog( | |
onDismissRequest = onDismissRequest, properties = DialogProperties( | |
dismissOnBackPress = true, dismissOnClickOutside = true, usePlatformDefaultWidth = false | |
) | |
) { | |
Column( | |
modifier = Modifier | |
.fillMaxSize() | |
.padding(16.dp), | |
horizontalAlignment = Alignment.CenterHorizontally, | |
verticalArrangement = Arrangement.Center | |
) { | |
Card( | |
modifier = Modifier | |
.fillMaxWidth() | |
.wrapContentHeight(), | |
colors = CardDefaults.cardColors(containerColor = Color(0xFFF7F7F8)), | |
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) | |
) { | |
Column( | |
modifier = Modifier.fillMaxWidth() | |
) { | |
Text( | |
text = "Select Date", style = TextStyle( | |
color = Color.Black, | |
fontSize = with(LocalDensity.current) { 14.dp.toSp() }, | |
fontWeight = FontWeight.SemiBold, | |
fontFamily = FontFamily(Font(R.font.sf_pro)) | |
), modifier = Modifier.padding(16.dp) | |
) | |
DateNavigationBar(calendarState, currentMonth) | |
HorizontalDivider( | |
modifier = Modifier | |
.fillMaxWidth() | |
.height(1.dp) | |
.background(Color(0xFFBABABA)) | |
) | |
WeekdayRow(modifier = Modifier.background(Color.White)) | |
CalendarWithSelectableDays( | |
calendarState = calendarState, | |
currentMonth = currentMonth, | |
selectedDay = selectedDay, | |
onDaySelected = { day -> selectedDay = day }) | |
if (selectedClock != null) { | |
Row(modifier = Modifier.background(Color.White)) { | |
SelectableTime( | |
selectedClock = selectedClock!!, | |
amPm = amPm, | |
onHourChange = { | |
selectedClock = selectedClock!!.withHour(it) | |
}, | |
onMinuteChange = { | |
selectedClock = selectedClock!!.withMinute(it) | |
}, | |
onAmPmChange = { newAmPm -> amPm = newAmPm } | |
) | |
} | |
} | |
HorizontalDivider( | |
modifier = Modifier | |
.fillMaxWidth() | |
.height(1.dp) | |
.background(Color(0xFFB9B9BA)) | |
) | |
Box( | |
modifier = Modifier | |
.fillMaxWidth() | |
.background(Color(0xFFF7F7F8)) | |
.padding(10.dp) | |
) { | |
TextButton( | |
onClick = { | |
onSelectDate(selectedDay, selectedClock, amPm) | |
}, modifier = Modifier.align(Alignment.CenterEnd) | |
) { | |
Text( | |
text = "Done", | |
color = Color(0xFF007AFF), | |
fontSize = with(LocalDensity.current) { 14.dp.toSp() }) | |
} | |
} | |
} | |
} | |
} | |
} | |
} | |
@OptIn(ExperimentalMaterial3Api::class) | |
@Composable | |
fun SelectableTime( | |
selectedClock: LocalTime, | |
amPm: String, | |
onHourChange: (Int) -> Unit, | |
onMinuteChange: (Int) -> Unit, | |
onAmPmChange: (String) -> Unit | |
) { | |
val interactionSource = remember { MutableInteractionSource() } | |
Box(modifier = Modifier.fillMaxWidth()) { | |
Row( | |
modifier = Modifier, verticalAlignment = Alignment.CenterVertically | |
) { | |
Text( | |
modifier = Modifier.padding(horizontal = 20.dp), text = "Time", style = TextStyle( | |
color = Color.Black, textAlign = TextAlign.Left, fontWeight = FontWeight.W300 | |
) | |
) | |
Spacer(modifier = Modifier.weight(1f, true)) | |
// Hour input (1-12) | |
Card( | |
modifier = Modifier | |
.wrapContentWidth() | |
.wrapContentHeight() | |
.padding(horizontal = 20.dp, vertical = 15.dp), | |
colors = CardDefaults.cardColors( | |
containerColor = Color(0xffF2F2F2), contentColor = Color(0xFF3478F6) | |
) | |
) { | |
Row( | |
modifier = Modifier | |
.wrapContentWidth() | |
.padding(horizontal = 10.dp), | |
verticalAlignment = Alignment.CenterVertically | |
) { | |
BasicTextField( | |
value = when { | |
selectedClock.hour == 0 && amPm == "AM" -> "12" // Midnight | |
selectedClock.hour == 12 && amPm == "PM" -> "12" // Noon | |
selectedClock.hour > 12 && amPm == "PM" -> (selectedClock.hour - 12).toString() | |
selectedClock.hour == 0 && amPm == "PM" -> "" // Should not happen with PM if logic is correct | |
else -> selectedClock.hour.toString() | |
}, | |
onValueChange = { value: String -> | |
val filtered = value.filter { it.isDigit() } | |
val numericValue = filtered.toIntOrNull() | |
if (filtered.length <= 2 && numericValue != null && numericValue in 1..12) { | |
val finalHour = if (amPm == "PM" && numericValue < 12) { | |
numericValue + 12 | |
} else if (amPm == "AM" && numericValue == 12) { | |
0 // Midnight | |
} else { | |
numericValue | |
} | |
onHourChange(finalHour) | |
} else if (filtered.isEmpty()) { | |
onHourChange(if (amPm == "AM") 0 else 12) // Default to 12 AM or 12 PM on empty | |
} | |
}, | |
modifier = Modifier | |
.background(Color.Transparent) | |
.width(20.dp), | |
singleLine = true, | |
textStyle = LocalTextStyle.current.copy( | |
fontWeight = FontWeight.W300, | |
fontSize = with(LocalDensity.current) { 14.dp.toSp() }, | |
textAlign = TextAlign.Center, | |
color = Color(0xFF3478F6) | |
), | |
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), | |
) { | |
TextFieldDefaults.DecorationBox( | |
value = when { | |
selectedClock.hour == 0 && amPm == "AM" -> "12" // Midnight | |
selectedClock.hour == 12 && amPm == "PM" -> "12" // Noon | |
selectedClock.hour > 12 && amPm == "PM" -> (selectedClock.hour - 12).toString() | |
selectedClock.hour == 0 && amPm == "PM" -> "" // Should not happen with PM if logic is correct | |
else -> selectedClock.hour.toString() | |
}, | |
innerTextField = it, | |
contentPadding = PaddingValues(0.dp), | |
enabled = true, | |
singleLine = true, | |
visualTransformation = VisualTransformation.None, | |
interactionSource = interactionSource, | |
colors = TextFieldDefaults.colors( | |
errorContainerColor = Color.Transparent, | |
focusedContainerColor = Color.Transparent, | |
disabledContainerColor = Color.Transparent, | |
unfocusedContainerColor = Color.Transparent, | |
focusedTextColor = Color(0xFF3478F6), | |
disabledTextColor = Color(0xFF3478F6), | |
unfocusedTextColor = Color(0xFF3478F6), | |
errorTextColor = Color(0xFF3478F6), | |
errorSupportingTextColor = Color(0xFF3478F6), | |
focusedSupportingTextColor = Color(0xFF3478F6), | |
unfocusedSupportingTextColor = Color(0xFF3478F6), | |
disabledSupportingTextColor = Color(0xFF3478F6), | |
disabledIndicatorColor = Color.Transparent, | |
focusedIndicatorColor = Color.Transparent, | |
errorIndicatorColor = Color.Transparent, | |
unfocusedIndicatorColor = Color.Transparent | |
) | |
) | |
} | |
// Immutable colon | |
Text( | |
text = ":", style = LocalTextStyle.current.copy( | |
fontSize = 32.sp, | |
fontWeight = FontWeight.W300, | |
), modifier = Modifier.padding(horizontal = 4.dp) | |
) | |
// Minute input (00-59) | |
BasicTextField( | |
value = selectedClock.minute.toString().padStart(2, '0'), | |
onValueChange = { value -> | |
val filtered = value.filter { it.isDigit() } | |
val numericValue = filtered.toIntOrNull() | |
if ((filtered.length <= 2) && (numericValue == null || (numericValue in 0..59))) { | |
onMinuteChange(numericValue ?: 0) | |
} | |
}, | |
modifier = Modifier | |
.background(Color.Transparent) | |
.width(20.dp), | |
singleLine = true, | |
textStyle = LocalTextStyle.current.copy( | |
fontWeight = FontWeight.W300, | |
textAlign = TextAlign.Center, color = Color(0xFF3478F6), | |
fontSize = with(LocalDensity.current) { 14.dp.toSp() }, | |
), | |
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number) | |
) { | |
TextFieldDefaults.DecorationBox( | |
value = selectedClock.minute.toString().padStart(2, '0'), | |
innerTextField = it, | |
contentPadding = PaddingValues(0.dp), | |
enabled = true, | |
singleLine = true, | |
visualTransformation = VisualTransformation.None, | |
interactionSource = interactionSource, | |
colors = TextFieldDefaults.colors( | |
errorContainerColor = Color.Transparent, | |
focusedContainerColor = Color.Transparent, | |
disabledContainerColor = Color.Transparent, | |
unfocusedContainerColor = Color.Transparent, | |
focusedTextColor = Color(0xFF3478F6), | |
disabledTextColor = Color(0xFF3478F6), | |
unfocusedTextColor = Color(0xFF3478F6), | |
errorTextColor = Color(0xFF3478F6), | |
errorSupportingTextColor = Color(0xFF3478F6), | |
focusedSupportingTextColor = Color(0xFF3478F6), | |
unfocusedSupportingTextColor = Color(0xFF3478F6), | |
disabledSupportingTextColor = Color(0xFF3478F6), | |
disabledIndicatorColor = Color.Transparent, | |
focusedIndicatorColor = Color.Transparent, | |
errorIndicatorColor = Color.Transparent, | |
unfocusedIndicatorColor = Color.Transparent | |
) | |
) | |
} | |
Text( | |
text = amPm, | |
modifier = Modifier | |
.padding(start = 8.dp) | |
.clickable { | |
val newAmPm = if (amPm == "AM") "PM" else "AM" | |
onAmPmChange(newAmPm) | |
// Adjust hour when AM/PM is toggled | |
val currentHour = selectedClock.hour | |
// Determine the new hour based on the *new* newAmPm | |
val adjustedHour = if (newAmPm == "PM") { | |
if (currentHour < 12) currentHour + 12 else if (currentHour == 12) 12 else currentHour // handles 12 AM to 12 PM | |
} else { // newAmPm == "AM" | |
if (currentHour == 0) 0 // Midnight already 0 | |
else if (currentHour > 12) currentHour - 12 // PM to AM (e.g. 13:00 to 1:00) | |
else if (currentHour == 12) 0 // 12 PM to 12 AM | |
else currentHour | |
} | |
onHourChange(adjustedHour) | |
} | |
.padding(vertical = 4.dp, horizontal = 8.dp), // Add some padding for better touch target | |
style = LocalTextStyle.current.copy( | |
fontWeight = FontWeight.W300, | |
fontSize = with(LocalDensity.current) { 14.dp.toSp() }, | |
color = Color(0xFF3478F6) | |
) | |
) | |
} | |
} | |
} | |
} | |
} | |
@Composable | |
private fun DateNavigationBar(calendarState: CalendarState, currentMonth: YearMonth) { | |
Row( | |
verticalAlignment = Alignment.CenterVertically, | |
horizontalArrangement = Arrangement.SpaceEvenly, | |
modifier = Modifier | |
.fillMaxWidth() | |
.padding(vertical = 8.dp) | |
) { | |
TimeNavigationBar( | |
modifier = Modifier.weight(1f), | |
currentTime = currentMonth?.month?.name ?: "", | |
onPreviousClick = { | |
CoroutineScope(Dispatchers.Main).launch { | |
calendarState.scrollToMonth(currentMonth.minusMonths(1)) | |
} | |
}, | |
onNextClick = { | |
CoroutineScope(Dispatchers.Main).launch { | |
calendarState.scrollToMonth(currentMonth.plusMonths(1)) | |
} | |
}) | |
TimeNavigationBar( | |
modifier = Modifier.weight(1f), | |
currentTime = currentMonth?.year?.toString() ?: "", | |
onPreviousClick = { | |
CoroutineScope(Dispatchers.Main).launch { | |
calendarState.scrollToMonth(currentMonth.minusYears(1)) | |
} | |
}, | |
onNextClick = { | |
CoroutineScope(Dispatchers.Main).launch { | |
calendarState.scrollToMonth(currentMonth.plusYears(1)) | |
} | |
}) | |
} | |
} | |
@Composable | |
fun TimeNavigationBar( | |
modifier: Modifier, | |
currentTime: String, | |
onPreviousClick: () -> Unit, | |
onNextClick: () -> Unit, | |
) { | |
Row( | |
horizontalArrangement = Arrangement.SpaceAround, | |
verticalAlignment = Alignment.CenterVertically, | |
modifier = modifier.background(Color(0xFFF9F9F9)) | |
) { | |
IconButton( | |
modifier = Modifier.size(15.dp), onClick = onPreviousClick | |
) { | |
Icon( | |
modifier = Modifier.fillMaxSize(), | |
painter = painterResource(id = R.drawable.left_chevron), | |
contentDescription = "Previous", | |
tint = Color(0xFF3478F6) | |
) | |
} | |
Text( | |
text = currentTime, modifier = Modifier.padding(), style = TextStyle( | |
color = Color(0xFF3478F6), | |
fontSize = with(LocalDensity.current) { 14.dp.toSp() }, | |
fontWeight = FontWeight.SemiBold | |
) | |
) | |
IconButton( | |
modifier = Modifier.size(15.dp), onClick = onNextClick | |
) { | |
Icon( | |
modifier = Modifier.fillMaxSize(), | |
painter = painterResource(id = R.drawable.right_chevron), | |
contentDescription = "Next", | |
tint = Color(0xFF3478F6) | |
) | |
} | |
} | |
} | |
@Composable | |
fun WeekdayRow( | |
modifier: Modifier = Modifier | |
) { | |
Row( | |
modifier = modifier | |
.fillMaxWidth() | |
.padding(0.dp), | |
horizontalArrangement = Arrangement.SpaceBetween | |
) { | |
listOf("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun").forEach { day -> | |
Text( | |
text = day, style = TextStyle( | |
fontSize = with(LocalDensity.current) { 12.dp.toSp() }, | |
color = Color(0xFF7E818C), | |
fontFamily = FontFamily(Font(R.font.sf_pro)) | |
), modifier = Modifier.weight(1f), textAlign = TextAlign.Center | |
) | |
} | |
} | |
} | |
@Composable | |
fun CalendarWithSelectableDays( | |
calendarState: CalendarState, | |
currentMonth: YearMonth?, | |
selectedDay: CalendarDay?, // Track the currently selected day | |
onDaySelected: (CalendarDay) -> Unit // Callback when a day is selected | |
) { | |
HorizontalCalendar( | |
modifier = Modifier.background(Color.White), | |
state = calendarState, | |
dayContent = { day -> // Renders each day | |
DaySelectionItem( | |
day = day, // Pass the CalendarDay | |
isCurrentMonth = day.date.yearMonth == currentMonth, // Check if it's part of the current month | |
isSelected = day == selectedDay, // Highlight the selected day | |
onDayClicked = { onDaySelected(it) } // Handle day selection and pass it to the parent | |
) | |
}, | |
userScrollEnabled = true, | |
calendarScrollPaged = true | |
) | |
} | |
@Composable | |
fun DaySelectionItem( | |
day: CalendarDay, // CalendarDay from kizitonwose library | |
isCurrentMonth: Boolean, // Whether the day belongs to the current month | |
isSelected: Boolean, // Whether the day is selected | |
onDayClicked: (CalendarDay) -> Unit, // Callback for day selection | |
modifier: Modifier = Modifier // Additional customization | |
) { | |
val backgroundColor = when { | |
isSelected -> Color(0xFF3478F6) // Highlight selected day | |
isCurrentMonth -> Color.White // Background for current month days | |
else -> Color.Transparent // Background for non-current month days | |
} | |
val textColor = if (isCurrentMonth) { | |
if (isSelected) Color.Black else Color(0xFF1C1C1E) // Dynamically change color based on selection | |
} else { | |
Color(0xFFC8C8C8) // Light gray for non-current month days | |
} | |
Box( | |
modifier = modifier | |
.size(48.dp) // Standard size for a day item | |
.clip(CircleShape) | |
.background(backgroundColor) | |
.clickable( | |
onClick = { if (isCurrentMonth) onDayClicked(day) }, // Enable click for current month only | |
enabled = isCurrentMonth, // Non-clickable for non-current month days | |
interactionSource = remember { MutableInteractionSource() }, | |
indication = null // Remove ripple effect for clean background change | |
), contentAlignment = Alignment.Center | |
) { | |
Text( | |
text = day.date.dayOfMonth.toString(), style = TextStyle( | |
color = textColor, | |
textAlign = TextAlign.Center, | |
fontFamily = FontFamily(Font(R.font.sf_pro)), | |
fontSize = with(LocalDensity.current) { 10.dp.toSp() }, | |
fontWeight = FontWeight.W300 | |
) | |
) | |
} | |
} | |
@Composable | |
fun Friends(friends: List<Friend>) { | |
Box(modifier = Modifier.fillMaxSize()) { | |
LazyVerticalGrid( | |
userScrollEnabled = false, | |
modifier = Modifier | |
.height(getScreenWidthInDp() / 3 * friends.size / 2) | |
.background(Color(0xFFEFEFF4)) | |
.padding(bottom = 56.dp), | |
columns = GridCells.Fixed(3), | |
contentPadding = PaddingValues(10.dp) | |
) { | |
itemsIndexed(friends) { index, item -> | |
Column( | |
modifier = Modifier | |
.aspectRatio(0.8f) | |
.padding(5.dp) | |
.background(Color.White, shape = RoundedCornerShape(10.dp)) | |
) { | |
Card( | |
modifier = Modifier | |
.padding(10.dp) | |
.align(Alignment.CenterHorizontally) | |
.size(50.dp), | |
shape = CircleShape, | |
colors = CardDefaults.cardColors(containerColor = Color(0xFFDBDCDE)) | |
) { | |
Image( | |
contentScale = ContentScale.Inside, | |
painter = rememberAsyncImagePainter( | |
model = item.avatar, | |
placeholder = painterResource(id = R.drawable.img_profile_placeholder), | |
error = painterResource(id = R.drawable.img_profile_placeholder) // Use placeholder if loading image fails | |
), | |
contentDescription = "Friend's photo", | |
modifier = Modifier | |
.fillMaxSize() | |
.align(Alignment.CenterHorizontally) | |
.clip(CircleShape) // Make the image circular | |
.background(Color.LightGray) // Optional background for contrast | |
) | |
} | |
Text( | |
modifier = Modifier | |
.padding(horizontal = 10.dp) | |
.align(Alignment.CenterHorizontally), | |
style = TextStyle( | |
textAlign = TextAlign.Center, | |
fontWeight = FontWeight.W300, | |
fontFamily = FontFamily(Font(R.font.sf_pro)), | |
color = Color.Black | |
), | |
text = item.firstName + " " + item.lastName, | |
maxLines = 1, | |
overflow = TextOverflow.Ellipsis | |
) | |
TextButton( | |
modifier = Modifier | |
.padding(5.dp) | |
.wrapContentSize(), | |
shape = RoundedCornerShape(4.dp), | |
colors = ButtonDefaults.buttonColors( | |
containerColor = Color(0xffF2F2F7), contentColor = Color.Black | |
), | |
onClick = { | |
// Provide functionality for removing a friend here | |
}) { | |
Text( | |
text = "Remove Friend", style = TextStyle( | |
fontWeight = FontWeight.W500, | |
fontFamily = FontFamily(Font(R.font.sf_pro)) | |
), maxLines = 1 | |
) | |
} | |
} | |
} | |
} | |
} | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\ui\ManageShared.kt | |
```kt | |
package com.divadventure.ui | |
import androidx.compose.foundation.background | |
import androidx.compose.foundation.layout.fillMaxWidth | |
import androidx.compose.foundation.layout.padding | |
import androidx.compose.material3.Text | |
import androidx.compose.runtime.Composable | |
import androidx.compose.ui.Modifier | |
import androidx.compose.ui.graphics.Color | |
import androidx.compose.ui.platform.LocalDensity | |
import androidx.compose.ui.text.TextStyle | |
import androidx.compose.ui.text.font.Font | |
import androidx.compose.ui.text.font.FontFamily | |
import androidx.compose.ui.text.font.FontWeight | |
import androidx.compose.ui.unit.dp | |
import com.divadventure.R | |
@Composable | |
fun GrayTitle(text: String) { | |
Text( | |
text = text, | |
modifier = Modifier | |
.fillMaxWidth() | |
.background(Color(0xFFEFEFF4)) | |
.padding(start = 10.dp, end = 10.dp, top = 20.dp, bottom = 10.dp), | |
style = TextStyle( | |
fontSize = with(LocalDensity.current) { 14.dp.toSp() }, | |
fontFamily = FontFamily(Font(R.font.sf_pro)), | |
fontWeight = FontWeight.W600, | |
) | |
) | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\ui\ProfileShared.kt | |
```kt | |
package com.divadventure.ui | |
import androidx.compose.foundation.layout.Box | |
import androidx.compose.foundation.layout.Column | |
import androidx.compose.foundation.layout.fillMaxWidth | |
import androidx.compose.foundation.layout.padding | |
import androidx.compose.foundation.shape.RoundedCornerShape | |
import androidx.compose.material3.ButtonDefaults | |
import androidx.compose.material3.ExperimentalMaterial3Api | |
import androidx.compose.material3.ModalBottomSheet | |
import androidx.compose.material3.SecondaryTabRow | |
import androidx.compose.material3.SheetValue | |
import androidx.compose.material3.Tab | |
import androidx.compose.material3.TabRowDefaults | |
import androidx.compose.material3.Text | |
import androidx.compose.material3.TextButton | |
import androidx.compose.material3.rememberModalBottomSheetState | |
import androidx.compose.runtime.Composable | |
import androidx.compose.runtime.LaunchedEffect | |
import androidx.compose.runtime.getValue | |
import androidx.compose.runtime.mutableStateOf | |
import androidx.compose.runtime.remember | |
import androidx.compose.runtime.rememberCoroutineScope | |
import androidx.compose.runtime.setValue | |
import androidx.compose.ui.Modifier | |
import androidx.compose.ui.graphics.Color | |
import androidx.compose.ui.platform.LocalDensity | |
import androidx.compose.ui.text.TextStyle | |
import androidx.compose.ui.unit.dp | |
import com.divadventure.viewmodel.ProfileUIEvent | |
import com.divadventure.viewmodel.ProfileViewModel | |
@Composable | |
fun ControlButton(text: String, onClick: () -> Unit) { | |
TextButton( | |
shape = RoundedCornerShape(5.dp), colors = ButtonDefaults.buttonColors( | |
containerColor = Color(0xFF30D158), | |
), modifier = Modifier | |
.fillMaxWidth() | |
.padding(20.dp), onClick = {}) { | |
Text( | |
text = text, fontSize = with(LocalDensity.current) { | |
14.dp.toSp() | |
}, modifier = Modifier.padding(5.dp), color = Color.White | |
) | |
} | |
} | |
@OptIn(ExperimentalMaterial3Api::class) | |
@Composable | |
fun TabbedProfileSwitcher( | |
adventuresContent: @Composable () -> Unit, | |
friendsContent: @Composable () -> Unit, | |
) { | |
// List of tab titles | |
val tabTitles = listOf("Adventures", "Friends") | |
var selectedTabIndex by remember { mutableStateOf(0) } | |
Column { | |
SecondaryTabRow( | |
divider = {}, | |
indicator = { | |
// Correctly access the `TabIndicatorScope` passed here | |
TabRowDefaults.SecondaryIndicator( | |
Modifier.tabIndicatorOffset(selectedTabIndex), | |
color = Color.Black // Set the indicator color to black | |
) | |
}, | |
selectedTabIndex = selectedTabIndex, | |
containerColor = Color.White, | |
) { | |
Tab( | |
selected = selectedTabIndex == 0, | |
onClick = { selectedTabIndex = 0 }, | |
text = { Text(tabTitles[0], style = TextStyle(color = Color.Gray)) } | |
) | |
Tab( | |
selected = selectedTabIndex == 1, | |
onClick = { selectedTabIndex = 1 }, | |
text = { Text(tabTitles[1], style = TextStyle(color = Color.Gray)) } | |
) | |
} | |
// Display content based on the selected tab | |
when (selectedTabIndex) { | |
0 -> adventuresContent() // Render first tab content | |
1 -> friendsContent() // Render second tab content | |
} | |
} | |
} | |
@Composable | |
fun ProfileSettings(profileViewModel: ProfileViewModel) { | |
@OptIn(androidx.compose.material3.ExperimentalMaterial3Api::class) ModalBottomSheet(dragHandle = { | |
Box { } | |
}, onDismissRequest = { }) { | |
val coroutineScope = rememberCoroutineScope() | |
// State for Modal Bottom Sheet | |
val modalBottomSheetState = rememberModalBottomSheetState( | |
skipPartiallyExpanded = false, | |
confirmValueChange = { it != SheetValue.PartiallyExpanded } // Prevents partial collapse | |
) | |
var showBottomSheet by remember { mutableStateOf(false) } | |
LaunchedEffect(key1 = true) { | |
profileViewModel.uiEvent.collect { event -> | |
when (event) { | |
ProfileUIEvent.AnimateItem -> {} | |
is ProfileUIEvent.NavigateToNextScreen -> {} | |
ProfileUIEvent.ShowDialog -> {} | |
is ProfileUIEvent.ShowDim -> {} | |
is ProfileUIEvent.ShowSnackbar -> { | |
} | |
is ProfileUIEvent.ShowBottomSheet -> { | |
showBottomSheet = true | |
} | |
} | |
} | |
} | |
} | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\viewmodel\AuthViewModel.kt | |
```kt | |
package com.divadventure.viewmodel | |
import android.os.Bundle | |
import android.util.Patterns | |
import androidx.lifecycle.viewModelScope | |
import androidx.navigation.NavController | |
import androidx.navigation.NavDestination | |
import com.divadventure.data.SharedService | |
import com.divadventure.data.navigation.NavigationEvent | |
import com.divadventure.data.navigation.NavigationEvent.NavigateTo | |
import com.divadventure.data.navigation.NavigationEvent.PopSpecific | |
import com.divadventure.data.navigation.Screen | |
import com.divadventure.di.AuthPrefs.VERIFICATION_PASSED_BOOLEAN | |
import com.divadventure.di.SharedPrefs | |
import com.divadventure.di.UserPrefs.KEY_EMAIL | |
import com.divadventure.di.UserPrefs.KEY_FIRST_NAME | |
import com.divadventure.di.UserPrefs.KEY_LAST_NAME | |
import com.divadventure.di.UserPrefs.KEY_TOKEN | |
import com.divadventure.di.UserPrefs.KEY_USERNAME | |
import com.divadventure.domain.models.Message | |
import com.divadventure.domain.models.ReqLogin | |
import com.divadventure.domain.models.ReqOnboard | |
import com.divadventure.domain.models.ReqVerifyEmail | |
import com.divadventure.domain.models.ReqVerifyResetPasswordToken | |
import com.divadventure.domain.models.ResVerifyEmail | |
import com.divadventure.domain.models.ResVerifyResetPasswordToken | |
import com.divadventure.domain.models.SignUpResponse | |
import com.divadventure.domain.models.SignupRequest | |
import com.divadventure.domain.models.UserData | |
import com.divadventure.ui.screens.UserDataManager | |
import com.divadventure.util.Helper.Companion.isEmailValid | |
import com.divadventure.util.NetworkManager | |
import com.divadventure.viewmodel.AuthUiEvent.ExecuteNavigation | |
import com.divadventure.viewmodel.AuthUiEvent.ShowSnackbar | |
import com.google.gson.Gson | |
import com.google.gson.annotations.SerializedName | |
import dagger.hilt.android.lifecycle.HiltViewModel | |
import kotlinx.coroutines.delay | |
import kotlinx.coroutines.flow.MutableSharedFlow | |
import kotlinx.coroutines.flow.asSharedFlow | |
import kotlinx.coroutines.launch | |
import retrofit2.Call | |
import retrofit2.Callback | |
import retrofit2.Response | |
import timber.log.Timber | |
import javax.inject.Inject | |
import javax.inject.Singleton | |
@HiltViewModel | |
class AuthViewModel @Inject constructor( | |
private val sharedService: SharedService, | |
private val sharedPrefs: SharedPrefs, | |
private val networkManager: NetworkManager | |
) : BaseViewModel<AuthIntent, AuthState>(AuthState()) { | |
private val _Auth_uiEvent = MutableSharedFlow<AuthUiEvent>() | |
val uiEvent = _Auth_uiEvent.asSharedFlow() | |
// Initialize handlers | |
private val splashHandler = SplashHandler() | |
private val landingHandler = LandingHandler(this) | |
private val signupHandler = SignupHandler(this) | |
private val onboardHandler = OnboardHandler(this) | |
private val verificationHandler = VerificationHandler(this) | |
private val loginHandler = LoginHandler(this) | |
private val changeEmailHandler = ChangeEmailHandler(this) | |
// private val verificationResendEmailHandler = VerificationResendEmailHandler(this) | |
private val forgotPasswordHandler = ForgotPasswordHandler(this) | |
private val resetPasswordHandler = ResetPasswordHandler(this) | |
//private val mutualHandler: MutualHandler by lazy { MutualHandler(this) } | |
private val mutualHandler = MutualHandler(this) | |
override suspend fun handleIntent(intent: AuthIntent) { | |
when (intent) { | |
is AuthIntent.SplashIntent -> splashHandler.handle(intent) | |
is AuthIntent.LandingIntent -> landingHandler.handle(intent) | |
is AuthIntent.SignupIntent -> signupHandler.handle(intent) | |
is AuthIntent.OnboardIntent -> onboardHandler.handle(intent) | |
is AuthIntent.VerificationIntent -> verificationHandler.handle(intent) | |
is AuthIntent.LoginIntent -> loginHandler.handle(intent) | |
is AuthIntent.ChangeEmailIntent -> changeEmailHandler.handle(intent) | |
is AuthIntent.ForgotPasswordIntent -> forgotPasswordHandler.handle(intent) | |
is AuthIntent.ResetPasswordIntent -> resetPasswordHandler.handle(intent) | |
is AuthIntent.MutualIntent -> mutualHandler.handle(intent) | |
} | |
} | |
//************************************************************************************************************************************************ | |
// Example handler: SplashHandler | |
inner class SplashHandler() { | |
suspend fun handle(intent: AuthIntent.SplashIntent) { | |
when (intent) { | |
AuthIntent.SplashIntent.CheckDecision -> handleCheckDecision() | |
} | |
} | |
private suspend fun handleCheckDecision() { | |
Timber.d("SplashIntent.CheckDecision triggered.") | |
delay(2000) | |
// navigateToLandingScreen() | |
if (isTokenEmpty()) { | |
navigateToLandingScreen() | |
} else { | |
Timber.d("Token present, proceeding to requestAccount.") | |
requestAccount() | |
} | |
} | |
private fun isTokenEmpty(): Boolean { | |
val token = sharedPrefs.getString(KEY_TOKEN) | |
return token.isNullOrEmpty().also { | |
if (it) Timber.i("Token is null or empty.") | |
} | |
} | |
private suspend fun navigateToLandingScreen() { | |
_Auth_uiEvent.emit( | |
ExecuteNavigation( | |
navigationEvent = NavigateTo( | |
screen = Screen.Landing, | |
popUpTo = Screen.Splash, | |
inclusive = true, | |
onDestinationChangedListener = createOnDestinationChangedListener() | |
) | |
) | |
) | |
} | |
private fun createOnDestinationChangedListener(): NavController.OnDestinationChangedListener { | |
return object : NavController.OnDestinationChangedListener { | |
override fun onDestinationChanged( | |
controller: NavController, destination: NavDestination, arguments: Bundle? | |
) { | |
// Timber.d("Navigated to ${destination.route}, removing listener.") | |
// controller.removeOnDestinationChangedListener(this@createOnDestinationChangedListener) | |
} | |
} | |
} | |
private fun requestAccount() { | |
val token = sharedPrefs.getString(KEY_TOKEN) | |
Timber.d("Retrieving current user with token: $token") | |
if (!isNetworkConnected()) { | |
notifyNoInternetConnection() | |
return | |
} | |
fetchCurrentUser(token) | |
} | |
private fun isNetworkConnected(): Boolean { | |
return networkManager.isNetworkConnected().also { | |
if (!it) Timber.w("No internet connection detected.") | |
} | |
} | |
private fun notifyNoInternetConnection() { | |
viewModelScope.launch { | |
_Auth_uiEvent.emit( | |
ShowSnackbar( | |
title = "Internet Issue", message = "No Internet Connection" | |
) | |
) | |
} | |
} | |
private fun fetchCurrentUser(token: String?) { | |
Timber.d("Starting fetchCurrentUser at: ${System.currentTimeMillis()} with token: $token") | |
sharedService.getCurrentUser("Bearer $token") | |
.enqueue(object : Callback<SignUpResponse> { | |
override fun onResponse( | |
call: Call<SignUpResponse?>, response: Response<SignUpResponse?> | |
) { | |
Timber.d("fetchCurrentUser onResponse received at: ${System.currentTimeMillis()}") | |
handleGetCurrentUserResponse(response) | |
Timber.d("fetchCurrentUser onResponse handling completed at: ${System.currentTimeMillis()}") | |
} | |
override fun onFailure(call: Call<SignUpResponse?>, t: Throwable) { | |
Timber.e("fetchCurrentUser onFailure triggered at: ${System.currentTimeMillis()} with error: ${t.message}") | |
viewModelScope.launch { | |
handleGetCurrentUserFailure(t) | |
Timber.d("fetchCurrentUser onFailure handling completed at: ${System.currentTimeMillis()}") | |
} | |
} | |
}) | |
} | |
private fun handleGetCurrentUserResponse(response: Response<SignUpResponse?>) { | |
val body = response.body() | |
if (response.isSuccessful && body != null) { | |
navigateToHomeScreen() | |
} else { | |
handleUnsuccessfulResponse() | |
} | |
} | |
private fun isOnboardingRequired(): Boolean { | |
return sharedPrefs.getString(KEY_USERNAME).isNullOrEmpty() || sharedPrefs.getString( | |
KEY_FIRST_NAME | |
).isNullOrEmpty() || sharedPrefs.getString(KEY_LAST_NAME).isNullOrEmpty() | |
} | |
private fun handleUnsuccessfulResponse() { | |
viewModelScope.launch { | |
navigateToLandingScreen() | |
} | |
} | |
private suspend fun handleGetCurrentUserFailure(t: Throwable) { | |
Timber.e("Request failed: ${t.message}") | |
_Auth_uiEvent.emit( | |
ShowSnackbar( | |
title = "Operation Failed", message = "An error occurred. Please try again." | |
) | |
) | |
navigateToLandingScreen() | |
} | |
private fun navigateToHomeScreen() { | |
Timber.i("Navigating to HomeScreen.") | |
navigateTo(Screen.Main, Screen.Splash) | |
} | |
/* | |
private fun navigateToVerificationScreen() { | |
Timber.i("Navigating to VerificationScreen.") | |
updateState( | |
state.value.copy( | |
verificationState = VerificationState( | |
email = sharedPrefs.getString(KEY_EMAIL) ?: "" | |
) | |
) | |
) | |
navigateTo(Screen.Verification, Screen.Splash) | |
} | |
*/ | |
private fun navigateTo(screen: Screen, popUpTo: Screen) { | |
viewModelScope.launch { | |
_Auth_uiEvent.emit( | |
ExecuteNavigation( | |
navigationEvent = NavigateTo( | |
screen = screen, | |
popUpTo = popUpTo, | |
inclusive = true, | |
onDestinationChangedListener = createOnDestinationChangedListenerForNavigation() | |
) | |
) | |
) | |
} | |
} | |
private fun createOnDestinationChangedListenerForNavigation(): NavController.OnDestinationChangedListener { | |
return object : NavController.OnDestinationChangedListener { | |
override fun onDestinationChanged( | |
controller: NavController, destination: NavDestination, arguments: Bundle? | |
) { | |
Timber.d("Navigated to ${destination.route}") | |
controller.removeOnDestinationChangedListener(this) | |
} | |
} | |
} | |
} | |
//************************************************************************************************************************************************ | |
inner class SignupHandler(private val viewModel: AuthViewModel) { | |
suspend fun handle(intent: AuthIntent.SignupIntent) { | |
when (intent) { | |
is AuthIntent.SignupIntent.SignUp -> handleSignUp(intent) | |
is AuthIntent.SignupIntent.SignUpWithGoogle -> Unit // Handle Google sign-up | |
is AuthIntent.SignupIntent.OnEmailChanged -> updateSignupState { | |
it.copy( | |
email = intent.email, | |
) | |
} | |
is AuthIntent.SignupIntent.OnPasswordChanged -> updateSignupState { | |
it.copy( | |
password = intent.password, startedTyping = true | |
) | |
} | |
is AuthIntent.SignupIntent.OnPasswordConfirmationChanged -> updateSignupState { | |
it.copy( | |
passwordConfirmation = intent.passwordConfirmation, startedTyping = true | |
) | |
} | |
} | |
} | |
private fun handleSignUp(intent: AuthIntent.SignupIntent.SignUp) { | |
if (!handlePreSignupChecks()) return | |
updateLoadingState(true) | |
sharedService.signup(SignupRequest(intent.email, intent.password, intent.password)) | |
.enqueue(signupCallback()) | |
} | |
private fun signupCallback() = object : Callback<SignUpResponse> { | |
override fun onResponse( | |
call: Call<SignUpResponse>, response: Response<SignUpResponse> | |
) { | |
if (response.isSuccessful) { | |
viewModel.viewModelScope.launch { processSignUpResponse(response) } | |
} else { | |
emitSnackbar( | |
"Error Sign up", extractErrorMessage(response) | |
) | |
} | |
} | |
override fun onFailure(call: Call<SignUpResponse>, t: Throwable) { | |
viewModel.viewModelScope.launch { | |
showSnackbar( | |
"Signup failed", t.message ?: "Unknown error" | |
) | |
} | |
finalizeSignupProcess() | |
emitSnackbar("Action Required", "Something went wrong, please try again.") | |
} | |
} | |
private fun emitSnackbar(title: String, message: String) { | |
Timber.d("Emitting snackbar with title: $title, message: $message") | |
viewModel.viewModelScope.launch { | |
viewModel._Auth_uiEvent.emit(ShowSnackbar(title, message)) | |
} | |
} | |
private suspend fun processSignUpResponse(response: Response<SignUpResponse>) { | |
response.body()?.let { | |
if (response.isSuccessful) { | |
saveUserData(it) | |
resetSignupState() | |
// sharedPrefs.setLong(SEND_OTP_TIME_MILLIS, System.currentTimeMillis()) | |
navigateToVerificationScreen() | |
} else showSnackbar("Signup failed", extractErrorMessage(response)) | |
} | |
finalizeSignupProcess() | |
} | |
private fun navigateToVerificationScreen() { | |
emitNavigationEvent( | |
NavigateTo( | |
screen = Screen.Verification, | |
popUpTo = Screen.SignUp, | |
inclusive = true, | |
onDestinationChangedListener = object : | |
NavController.OnDestinationChangedListener { | |
override fun onDestinationChanged( | |
controller: NavController, | |
destination: NavDestination, | |
arguments: Bundle? | |
) { | |
Timber.d("Navigated to ${destination.route}") | |
controller.removeOnDestinationChangedListener(this) | |
} | |
}) | |
) | |
} | |
private suspend fun updateSignupState(update: (SignupState) -> SignupState) { | |
viewModel.updateState(state.value.copy(signupState = update(state.value.signupState!!))) | |
validateSignupForm() | |
} | |
private fun validateSignupForm() { | |
state.value.signupState?.let { | |
val formError = validateSignupFields(it.email, it.password, it.passwordConfirmation) | |
viewModel.updateState( | |
state.value.copy( | |
signupState = it.copy( | |
formError = formError.orEmpty(), | |
formDataValid = formError == null, | |
passwordsMatch = it.password == it.passwordConfirmation, | |
is8Characters = it.password.length >= 8 | |
) | |
) | |
) | |
} | |
} | |
private fun validateSignupFields( | |
email: String, password: String, passwordConfirmation: String | |
): String? { | |
return when { | |
email.isBlank() -> "Email can't be empty" | |
!isEmailValid(email) -> "Email is not valid" | |
password.isBlank() -> "Password can't be empty" | |
password.length < 8 -> "Password must have at least 8 characters" | |
password != passwordConfirmation -> "Passwords do not match" | |
else -> null | |
} | |
} | |
private fun handlePreSignupChecks(): Boolean { | |
if (!networkManager.isNetworkConnected()) { | |
viewModel.viewModelScope.launch { | |
showSnackbar( | |
"Internet Issue", "No Internet Connection" | |
) | |
} | |
return false | |
} | |
return state.value.signupState?.formDataValid == true | |
} | |
private fun finalizeSignupProcess() { | |
enableSignupButton() | |
updateLoadingState(false) | |
} | |
private fun showSnackbar(title: String, message: String) { | |
viewModel.viewModelScope.launch { | |
viewModel._Auth_uiEvent.emit( | |
ShowSnackbar( | |
title, message | |
) | |
) | |
} | |
} | |
private fun updateLoadingState(isLoading: Boolean) { | |
viewModel.updateState( | |
state.value.copy( | |
signupState = state.value.signupState?.copy( | |
isLoadingSignUp = isLoading | |
) | |
) | |
) | |
} | |
private fun enableSignupButton() { | |
viewModel.updateState( | |
state.value.copy( | |
signupState = state.value.signupState?.copy( | |
signupClickable = true | |
) | |
) | |
) | |
} | |
private fun saveUserData(body: SignUpResponse) { | |
UserDataManager(sharedPrefs).savePrimaryData(body) | |
} | |
private fun resetSignupState() { | |
viewModel.updateState( | |
state.value.copy( | |
signupState = SignupState(), verificationState = VerificationState( | |
email = state.value.signupState!!.email | |
) | |
) | |
) | |
} | |
private fun emitNavigationEvent(navigationEvent: NavigateTo) { | |
viewModel.viewModelScope.launch { | |
viewModel._Auth_uiEvent.emit( | |
ExecuteNavigation( | |
navigationEvent | |
) | |
) | |
} | |
} | |
private fun extractErrorMessage(response: Response<SignUpResponse>) = | |
response.errorBody()?.string() | |
?.let { Gson().fromJson(it, Message::class.java)?.message } ?: "Unknown Error" | |
} | |
//************************************************************************************************************************************************ | |
inner class LandingHandler(private val viewModel: AuthViewModel) { | |
suspend fun handle(intent: AuthIntent.LandingIntent) { | |
when (intent) { | |
is AuthIntent.LandingIntent.gotoLogin -> navigateToLogin() | |
is AuthIntent.LandingIntent.gotoSignup -> navigateToSignup() | |
} | |
} | |
private suspend fun navigateToLogin() { | |
Timber.d("Navigating to LoginScreen.") | |
viewModel.updateState(viewModel.state.value.copy(loginState = LoginState())) | |
viewModel._Auth_uiEvent.emit( | |
ExecuteNavigation( | |
navigationEvent = NavigateTo( | |
screen = Screen.Login, | |
popUpTo = Screen.Landing, | |
inclusive = false, | |
onDestinationChangedListener = createOnDestinationChangedListener() | |
) | |
) | |
) | |
} | |
private suspend fun navigateToSignup() { | |
Timber.d("Navigating to SignUpScreen.") | |
viewModel.updateState(viewModel.state.value.copy(signupState = SignupState())) | |
viewModel._Auth_uiEvent.emit( | |
ExecuteNavigation( | |
navigationEvent = NavigateTo( | |
screen = Screen.SignUp, | |
popUpTo = Screen.Landing, | |
inclusive = false, | |
onDestinationChangedListener = createOnDestinationChangedListener() | |
) | |
) | |
) | |
} | |
private fun createOnDestinationChangedListener(): NavController.OnDestinationChangedListener { | |
return NavController.OnDestinationChangedListener { controller, destination, _ -> | |
Timber.d("Navigated to ${destination.route}, removing listener.") | |
} | |
} | |
} | |
//************************************************************************************************************************************************ | |
inner class OnboardHandler(private val viewModel: AuthViewModel) { | |
suspend fun handle(intent: AuthIntent.OnboardIntent) { | |
when (intent) { | |
is AuthIntent.OnboardIntent.OnFirstNameChanged -> handleFirstNameChange(intent.firstName) | |
is AuthIntent.OnboardIntent.OnLastNameChanged -> handleLastNameChange(intent.lastName) | |
is AuthIntent.OnboardIntent.OnUserNameChanged -> handleUserNameChange(intent.userName) | |
is AuthIntent.OnboardIntent.Onboard -> startOnboardingProcess(intent) | |
} | |
} | |
private fun handleFirstNameChange(firstName: String) { | |
Timber.d("First name changed: $firstName") | |
updateOnboardState { it.copy(firstName = firstName) } | |
validateOnboardingData() | |
} | |
private fun handleLastNameChange(lastName: String) { | |
Timber.d("Last name changed: $lastName") | |
updateOnboardState { it.copy(lastName = lastName) } | |
validateOnboardingData() | |
} | |
private fun handleUserNameChange(userName: String) { | |
Timber.d("Username changed: $userName") | |
updateOnboardState { it.copy(userName = userName) } | |
validateOnboardingData() | |
} | |
private suspend fun startOnboardingProcess(intent: AuthIntent.OnboardIntent.Onboard) { | |
Timber.d("Onboarding process started.") | |
if (!isNetworkConnectedWithSnackbar()) return | |
val onboardState = state.value.onboardState ?: return | |
if (!onboardState.formDataValid) { | |
showOnboardingSnackbar("Onboarding Failed", onboardState.error ?: "Invalid data.") | |
return | |
} | |
// viewModel.updateLoadingState(true) | |
sharedService.onboard( | |
bearer = "Bearer ${sharedPrefs.getString(KEY_TOKEN)}", request = ReqOnboard( | |
firstName = onboardState.firstName, | |
lastName = onboardState.lastName, | |
username = onboardState.userName | |
) | |
).enqueue(createOnboardCallback(intent)) | |
} | |
private fun saveToSharedPrefs(key: String, value: String) { | |
sharedPrefs.setString(key, value) | |
} | |
private fun createOnboardCallback(onboardIntent: AuthIntent.OnboardIntent.Onboard) = | |
object : Callback<SignUpResponse> { | |
override fun onResponse( | |
call: Call<SignUpResponse>, response: Response<SignUpResponse> | |
) { | |
if (response.isSuccessful) { | |
Timber.d("Onboarding succeeded") | |
resetOnboardingState() | |
saveToSharedPrefs(KEY_USERNAME, onboardIntent.userName) | |
Timber.d("Username saved in shared preferences: ${onboardIntent.userName}") | |
saveToSharedPrefs(KEY_FIRST_NAME, onboardIntent.firstName) | |
Timber.d("First name saved in shared preferences: ${onboardIntent.firstName}") | |
saveToSharedPrefs(KEY_LAST_NAME, onboardIntent.lastName) | |
viewModelScope.launch { | |
_Auth_uiEvent.emit( | |
ExecuteNavigation( | |
PopSpecific( | |
Screen.Landing, | |
false | |
) | |
) | |
) | |
} | |
viewModel.viewModelScope.launch { | |
viewModel._Auth_uiEvent.emit( | |
ExecuteNavigation( | |
navigationEvent = NavigateTo( | |
screen = Screen.Main, | |
popUpTo = Screen.Landing, | |
inclusive = true, | |
onDestinationChangedListener = object : | |
NavController.OnDestinationChangedListener { | |
override fun onDestinationChanged( | |
controller: NavController, | |
destination: NavDestination, | |
arguments: Bundle? | |
) { | |
Timber.d("Navigated to ${destination.route}") | |
controller.removeOnDestinationChangedListener(this) | |
} | |
}) | |
) | |
) | |
} | |
} else { | |
Timber.e("Onboarding failed with: ${response.code()}") | |
showOnboardingSnackbar("Onboarding Failed", "Server error occurred.") | |
} | |
// viewModel.updateLoadingState(false) | |
} | |
override fun onFailure(call: Call<SignUpResponse>, t: Throwable) { | |
Timber.e("Onboarding request failed: ${t.message}") | |
showOnboardingSnackbar("Onboarding Failed", "Network error occurred.") | |
// viewModel.updateLoadingState(false) | |
} | |
} | |
private fun isNetworkConnectedWithSnackbar(): Boolean { | |
if (!networkManager.isNetworkConnected()) { | |
showOnboardingSnackbar("Internet Issue", "No Internet Connection") | |
return false | |
} | |
return true | |
} | |
private fun showOnboardingSnackbar(title: String, message: String) { | |
viewModelScope.launch { | |
viewModel._Auth_uiEvent.emit(ShowSnackbar(title, message)) | |
} | |
} | |
private fun resetOnboardingState() { | |
viewModel.updateState( | |
state.value.copy(onboardState = OnboardState()) | |
) | |
} | |
private fun updateOnboardState(update: (OnboardState) -> OnboardState) { | |
viewModel.updateState( | |
state.value.copy( | |
onboardState = state.value.onboardState?.let(update) | |
) | |
) | |
} | |
private fun validateOnboardingData() { | |
val onboardState = state.value.onboardState ?: return | |
val error = when { | |
onboardState.firstName.isBlank() -> "First name can't be empty" | |
onboardState.lastName.isBlank() -> "Last name can't be empty" | |
onboardState.userName.isBlank() -> "Username can't be empty" | |
else -> null | |
} | |
val isValid = error == null | |
updateOnboardState { | |
it.copy( | |
error = error.orEmpty(), formDataValid = isValid | |
) | |
} | |
} | |
} | |
//************************************************************************************************************************************************ | |
inner class VerificationHandler(private val viewModel: AuthViewModel) {/*init { | |
Timber.d("VerificationHandler initialized.") | |
startOtpCountdown() | |
}*/ | |
suspend fun handle(intent: AuthIntent.VerificationIntent) { | |
Timber.d("Handling VerificationIntent: $intent") | |
when (intent) { | |
is AuthIntent.VerificationIntent.OnOtpVerifyPressed -> handleOtpVerification() | |
is AuthIntent.VerificationIntent.OnOtpChanged -> handleOtpChanged(intent.otp) | |
is AuthIntent.VerificationIntent.GotoChangeEmail -> navigateToChangeEmail( | |
intent.email | |
) | |
is AuthIntent.VerificationIntent.ResendCode -> { | |
Timber.d("ResendCode intent triggered, remaining time: ${state.value.verificationState?.otpRemainTime}") | |
if (state.value.verificationState?.otpRemainTime?.toInt() == 0) { | |
requestResendOtp( | |
"Bearer ${sharedPrefs.getString(KEY_TOKEN)}", Screen.Verification | |
) | |
} else { | |
emitSnackbar( | |
"Resend not available", | |
"Resend code available in ${state.value.verificationState!!.otpRemainTime}" | |
) | |
} | |
} | |
is AuthIntent.VerificationIntent.UpdateTimer -> { | |
updateState( | |
state.value.copy( | |
verificationState = state.value.verificationState?.copy( | |
otpRemainTime = intent.time | |
) | |
) | |
) | |
} | |
} | |
} | |
private fun requestResendOtp(token: String, fromScreen: Screen) { | |
Timber.d("Attempting to resend OTP for token: $token") | |
if (!viewModel.networkManager.isNetworkConnected()) { | |
Timber.e("Network not connected. Cannot resend OTP.") | |
emitSnackbar("Internet Issue", "No Internet Connection") | |
return | |
} | |
viewModel.sharedService.resendVerificationEmail(token) | |
.enqueue(object : Callback<ResVerifyEmail> { | |
override fun onResponse( | |
call: Call<ResVerifyEmail?>, response: Response<ResVerifyEmail?> | |
) { | |
Timber.d("Resend OTP response received: ${response.code()}") | |
if (response.isSuccessful && response.body() != null) { | |
handleSuccessfulResendOtp() | |
} else if (response.code() == 425) { | |
Timber.w("Resend failed due to 425 response code: Too many requests.") | |
emitSnackbar( | |
"Re-sent Verification Code failed", | |
"server is refusing to process a request due to potential replay attacks or other security concerns." | |
) | |
} | |
} | |
override fun onFailure(call: Call<ResVerifyEmail?>, t: Throwable) { | |
Timber.e("Resend OTP request failed: ${t.localizedMessage}") | |
emitSnackbar("Action Required", "Something went wrong, please try again.") | |
} | |
}) | |
} | |
private fun handleSuccessfulResendOtp() { | |
Timber.d("Resend OTP success. Starting countdown.") | |
viewModel.viewModelScope.launch { | |
emitSnackbar( | |
"Re-sent Verification Link", | |
"A new verification link has been sent to your email." | |
) | |
} | |
} | |
private fun emitSnackbar(title: String, message: String) { | |
Timber.d("Emitting snackbar with title: $title, message: $message") | |
viewModel.viewModelScope.launch { | |
viewModel._Auth_uiEvent.emit(ShowSnackbar(title, message)) | |
} | |
} | |
private suspend fun handleOtpVerification() { | |
Timber.d("Handling OTP Verification.") | |
if (!viewModel.networkManager.isNetworkConnected()) { | |
Timber.e("Network not connected. Cannot verify OTP.") | |
emitUiEvent("Internet Issue", "No Internet Connection") | |
return | |
} | |
val token = "Bearer ${viewModel.sharedPrefs.getString(KEY_TOKEN)}" | |
val otpCode = viewModel.state.value.verificationState?.otpCode.orEmpty() | |
Timber.d("Verifying OTP with token: $token and otpCode: $otpCode") | |
viewModel.sharedService.verifyEmail(token, ReqVerifyEmail(token = otpCode)) | |
.enqueue(createOtpVerificationCallback()) | |
updateVerificationState { it.copy(permitEmailRevision = true) } | |
} | |
private fun createOtpVerificationCallback() = object : Callback<ResVerifyEmail> { | |
override fun onResponse( | |
call: Call<ResVerifyEmail?>, response: Response<ResVerifyEmail?> | |
) { | |
Timber.d("OTP verification response received: ${response.code()}") | |
when { | |
response.isSuccessful && response.body() != null -> viewModel.viewModelScope.launch { | |
Timber.d("OTP verification successful.") | |
handleSuccessfulVerification() | |
} | |
response.code() == 410 -> { | |
Timber.w("OTP verification failed: Token is invalid.") | |
emitUiEvent("OTP Error", "The time has been finished.") | |
} | |
else -> { | |
Timber.e("OTP verification failed with code: ${response.code()}.") | |
handleVerificationError(response) | |
} | |
} | |
} | |
override fun onFailure(call: Call<ResVerifyEmail?>, t: Throwable) { | |
Timber.e("OTP verification request failed: ${t.localizedMessage}") | |
emitUiEvent("Wrong OTP", t.message.toString()) | |
} | |
} | |
private suspend fun handleSuccessfulVerification() { | |
Timber.d("Handling successful OTP verification.")/* sharedPrefs.setLong( | |
SEND_OTP_TIME_MILLIS, System.currentTimeMillis() | |
)*/ | |
viewModel.updateState( | |
viewModel.state.value.copy( | |
onboardState = OnboardState(), verificationState = VerificationState() | |
) | |
) | |
viewModel._Auth_uiEvent.emit( | |
ExecuteNavigation(createNavigation(Screen.Onboarding, Screen.Verification)) | |
) | |
viewModel.sharedPrefs.setBoolean(VERIFICATION_PASSED_BOOLEAN, true) | |
} | |
private fun handleVerificationError(response: Response<ResVerifyEmail?>) { | |
Timber.e("Handling OTP verification error. Response code: ${response.code()}") | |
val errorBody = | |
Gson().fromJson<Message>(response.errorBody()?.string(), Message::class.java) | |
Timber.e("Error body message: ${errorBody?.message}") | |
emitUiEvent("OTP Error", errorBody?.message.orEmpty()) | |
} | |
private suspend fun handleOtpChanged(otp: String) { | |
Timber.d("Handling OTP change: $otp") | |
updateVerificationState { it.copy(otpCode = otp, isOtpCorrect = otp.length == 6) } | |
if (otp.length == 6) { | |
Timber.d("OTP is valid. Attempting verification.") | |
handleOtpVerification() | |
} | |
} | |
private suspend fun navigateToChangeEmail(email: String) { | |
Timber.d("Navigating to VerificationChangeEmail with email: $email") | |
updateVerificationChangeEmailState(email) | |
viewModel._Auth_uiEvent.emit( | |
ExecuteNavigation( | |
createNavigation( | |
Screen.VerificationChangeEmail, Screen.Verification, false | |
) | |
) | |
) | |
} | |
private fun createNavigation(screen: Screen, popUpTo: Screen, inclusive: Boolean = true) = | |
NavigateTo(screen, popUpTo, inclusive, createDestinationChangedListener()) | |
private fun createDestinationChangedListener() = | |
object : NavController.OnDestinationChangedListener { | |
override fun onDestinationChanged( | |
controller: NavController, destination: NavDestination, arguments: Bundle? | |
) { | |
Timber.d("Navigation changed to destination: ${destination.route}") | |
controller.removeOnDestinationChangedListener(this) | |
} | |
} | |
private fun updateVerificationState(update: (VerificationState) -> VerificationState) { | |
Timber.d("Updating verification state.") | |
viewModel.updateState( | |
viewModel.state.value.copy( | |
verificationState = viewModel.state.value.verificationState?.let(update) | |
) | |
) | |
} | |
private fun updateVerificationChangeEmailState(email: String) { | |
Timber.d("Updating VerificationChangeEmailState with email: $email") | |
viewModel.updateState( | |
viewModel.state.value.copy(ChangeEmailState = ChangeEmailState(email = email)) | |
) | |
} | |
private fun emitUiEvent(title: String, message: String) { | |
Timber.d("Emitting UI Event with title: $title, message: $message") | |
viewModel.viewModelScope.launch { | |
viewModel._Auth_uiEvent.emit(ShowSnackbar(title, message)) | |
} | |
} | |
} | |
//************************************************************************************************************************************************ | |
inner class LoginHandler(private val viewModel: AuthViewModel) { | |
suspend fun handle(intent: AuthIntent.LoginIntent) { | |
when (intent) { | |
is AuthIntent.LoginIntent.Login -> { | |
Timber.d("Handling LoginIntent.Login.") | |
handleLoginIntent(intent) | |
} | |
is AuthIntent.LoginIntent.OnForgotPasswordChanged -> { | |
Timber.d("Handling OnForgotPasswordChanged with email: ${intent.email}") | |
handleForgotPasswordChange(intent) | |
} | |
is AuthIntent.LoginIntent.ForgotPassword -> { | |
Timber.d("Handling ForgotPasswordIntent for email: ${intent.forgotEmail}") | |
handleForgotPassword(intent) | |
} | |
is AuthIntent.LoginIntent.OnEmailChanged -> { | |
Timber.d("Handling OnEmailChanged with email: ${intent.email}") | |
handleEmailChange(intent) | |
} | |
is AuthIntent.LoginIntent.OnPasswordChanged -> { | |
Timber.d("Handling OnPasswordChanged.") | |
handlePasswordChange(intent) | |
} | |
} | |
} | |
private fun handleLoginIntent(intent: AuthIntent.LoginIntent.Login) { | |
Timber.i("Login intent received with email/username: ${intent.emailOrUsername}.") | |
if (state.value.loginState!!.loginClickable == false) { | |
Timber.w("Login button is not clickable. Skipping login process.") | |
return | |
} | |
checkLogin(intent.emailOrUsername, intent.password) | |
} | |
private fun handleForgotPasswordChange(intent: AuthIntent.LoginIntent.OnForgotPasswordChanged) { | |
Timber.d( | |
if (isEmailValid(intent.email)) "Email is valid for forgot password." | |
else "Email is invalid for forgot password." | |
) | |
Timber.d("Updating forgetPasswordEmailCorrect to: ${isEmailValid(intent.email)}") | |
viewModel.updateState( | |
viewModel.state.value.copy( | |
loginState = viewModel.state.value.loginState!!.copy( | |
forgetPasswordEmailCorrect = isEmailValid(intent.email), | |
email = intent.email | |
) | |
) | |
) | |
} | |
private fun handleForgotPassword(intent: AuthIntent.LoginIntent.ForgotPassword) { | |
Timber.i("Processing forgot password for email: ${intent.forgotEmail}") | |
if (viewModel.state.value.loginState!!.forgetPasswordEmailCorrect == false) { | |
Timber.w("Forgot password attempt with invalid email format.") | |
return | |
} | |
if (!viewModel.networkManager.isNetworkConnected()) { | |
Timber.e("Network is not connected. Cannot proceed with forgot password.") | |
emitUiEvent("Internet Issue", "No Internet Connection") | |
return | |
} | |
Timber.i("Initiating forgot password request.") | |
viewModel.sharedService.forgotPassword(ResVerifyEmail(email = intent.forgotEmail)) | |
.enqueue(createForgotPasswordCallback(intent.forgotEmail)) | |
} | |
private fun handleEmailChange(intent: AuthIntent.LoginIntent.OnEmailChanged) { | |
Timber.d("Email changed to: ${intent.email}") | |
viewModel.updateState( | |
viewModel.state.value.copy( | |
loginState = viewModel.state.value.loginState!!.copy(email = intent.email) | |
) | |
) | |
Timber.d("Re-validating login fields after email change.") | |
validationLogin( | |
viewModel.state.value.loginState!!.email, | |
viewModel.state.value.loginState!!.password | |
) | |
} | |
private fun handlePasswordChange(intent: AuthIntent.LoginIntent.OnPasswordChanged) { | |
Timber.d("Password change detected.") | |
viewModel.updateState( | |
viewModel.state.value.copy( | |
loginState = viewModel.state.value.loginState!!.copy(password = intent.password) | |
) | |
) | |
Timber.d("Re-validating login fields after password change.") | |
validationLogin( | |
viewModel.state.value.loginState!!.email, | |
viewModel.state.value.loginState!!.password | |
) | |
} | |
private fun checkLogin(emailOrUsername: String, password: String) { | |
Timber.d("Checking login with email/username: $emailOrUsername.") | |
if (!viewModel.networkManager.isNetworkConnected()) { | |
Timber.e("Network is not connected. Cannot proceed with login.") | |
emitUiEvent("Internet Issue", "No Internet Connection") | |
return | |
} | |
when { | |
password.isEmpty() -> { | |
Timber.w("Login failed: Password is empty.") | |
emitUiEvent("Login failed", "Password is empty") | |
} | |
emailOrUsername.isEmpty() -> { | |
Timber.w("Login failed: Username or email is empty.") | |
emitUiEvent("Login failed", "Username or email is empty") | |
} | |
else -> { | |
Timber.i("All fields valid. Proceeding with login.") | |
performLogin(emailOrUsername, password) | |
} | |
} | |
} | |
private fun performLogin(emailOrUsername: String, password: String) { | |
Timber.d("Sending login request with email/username: $emailOrUsername.") | |
viewModel.sharedService.login(ReqLogin(email = emailOrUsername, password = password)) | |
.enqueue(createLoginCallback()) | |
} | |
private fun validationLogin(email: String, password: String) { | |
Timber.d("Validating login fields: email=$email, password length=${password.length}.") | |
val loginState = when { | |
email.isEmpty() || password.isEmpty() -> { | |
Timber.w("Validation failed: Either email or password is empty.") | |
LoginState.ERROR_EMPTY_FIELDS | |
} | |
!isEmailValid(email) -> { | |
Timber.w("Validation failed: Invalid email format.") | |
LoginState.ERROR_INVALID_EMAIL | |
} | |
else -> { | |
Timber.d("Validation successful: All fields are valid.") | |
LoginState.VALID | |
} | |
} | |
Timber.d("Updating login state with validation results.") | |
viewModel.updateState( | |
viewModel.state.value.copy( | |
loginState = viewModel.state.value.loginState!!.copy( | |
formError = loginState.errorMessage, | |
formDataValid = loginState.isValid, | |
loginClickable = isEmailValid(state.value.loginState!!.email) && state.value.loginState!!.password.isNotEmpty() | |
) | |
) | |
) | |
} | |
private fun createForgotPasswordCallback(email: String) = object : Callback<Unit> { | |
override fun onResponse(call: Call<Unit?>, response: Response<Unit?>) { | |
if (response.isSuccessful) { | |
Timber.i("Forgot password email sent successfully to $email.") | |
viewModel.viewModelScope.launch { | |
Timber.d("Navigating to ForgotPasswordVerification screen.") | |
navigateToForgotPasswordVerification() | |
} | |
} else { | |
Timber.e("Failed to send forgot password email to $email. Response code: ${response.code()}") | |
emitUiEvent("An error occurred", "Forgot Password email not sent successfully") | |
} | |
} | |
override fun onFailure(call: Call<Unit?>, t: Throwable) { | |
Timber.e("Failed to send forgot password email due to: ${t.message}") | |
emitUiEvent("An error occurred", "Forgot Password email not sent successfully") | |
} | |
} | |
private suspend fun navigateToForgotPasswordVerification() { | |
Timber.d("Updating ForgotPasswordState before navigation.") | |
updateForgotPasswordState() | |
Timber.i("Navigating to ForgotPassword screen.") | |
viewModel._Auth_uiEvent.emit( | |
ExecuteNavigation( | |
navigationEvent = NavigateTo( | |
screen = Screen.ForgotPassword, | |
popUpTo = Screen.Login, | |
inclusive = true, | |
onDestinationChangedListener = createDestinationChangedListener() | |
) | |
) | |
) | |
} | |
private fun updateForgotPasswordState() { | |
Timber.d("Setting ForgotPasswordState with email: ${state.value.loginState!!.email}") | |
viewModel.updateState( | |
viewModel.state.value.copy( | |
forgotPasswordState = ForgotPasswordState( | |
email = state.value.loginState!!.email | |
) | |
) | |
) | |
} | |
private fun createLoginCallback() = object : Callback<SignUpResponse> { | |
override fun onResponse( | |
call: Call<SignUpResponse?>, response: Response<SignUpResponse?> | |
) { | |
if (response.isSuccessful && response.body() != null) { | |
Timber.i("Login successful. Saving user data.") | |
val body = response.body()!! | |
UserDataManager(viewModel.sharedPrefs).savePrimaryData(body) | |
onLoginSuccess() | |
} else { | |
Timber.e( | |
"Login failed. Response code: ${response.code()}, errorBody: ${ | |
response.errorBody()?.string() | |
}" | |
) | |
emitUiEvent("Login failed", "Invalid credentials") | |
} | |
} | |
override fun onFailure(call: Call<SignUpResponse?>, t: Throwable) { | |
Timber.e("Login request failed due to: ${t.message}") | |
val errorMessage = | |
t.localizedMessage ?: "An unexpected error occurred. Please try again later." | |
emitUiEvent("Login failed", errorMessage) | |
} | |
} | |
private fun onLoginSuccess() { | |
Timber.i("Login process completed successfully.") | |
viewModelScope.launch { | |
_Auth_uiEvent.emit(ExecuteNavigation(PopSpecific(Screen.Landing, false))) | |
} | |
viewModel.viewModelScope.launch { | |
emitUiEvent("You have entered your account successfully", "Login successful") | |
Timber.i("Navigating to Home screen.") | |
viewModel._Auth_uiEvent.emit( | |
ExecuteNavigation( | |
navigationEvent = NavigateTo( | |
screen = Screen.Main, | |
popUpTo = Screen.Landing, | |
inclusive = true, | |
onDestinationChangedListener = createDestinationChangedListener() | |
) | |
) | |
) | |
} | |
} | |
private fun emitUiEvent(title: String, message: String) { | |
Timber.d("Emitting UI event with title: $title, message: $message") | |
viewModel.viewModelScope.launch { | |
viewModel._Auth_uiEvent.emit(ShowSnackbar(title, message)) | |
} | |
} | |
private fun createDestinationChangedListener() = | |
object : NavController.OnDestinationChangedListener { | |
override fun onDestinationChanged( | |
controller: NavController, destination: NavDestination, arguments: Bundle? | |
) { | |
Timber.d("Navigation occurred to destination: ${destination.route}") | |
} | |
} | |
private fun isEmailValid(email: String): Boolean { | |
Timber.d("Validating email: $email") | |
return Patterns.EMAIL_ADDRESS.matcher(email).matches() | |
} | |
} | |
private enum class LoginState(val errorMessage: String, val isValid: Boolean) { | |
ERROR_EMPTY_FIELDS( | |
"Email or password can't be empty", false | |
), | |
ERROR_INVALID_EMAIL("Email is not valid", false), VALID("", true) | |
} | |
//************************************************************************************************************************************************ | |
inner class ChangeEmailHandler(private val viewModel: AuthViewModel) { | |
suspend fun handle(intent: AuthIntent.ChangeEmailIntent) { | |
when (intent) { | |
is AuthIntent.ChangeEmailIntent.UpdateEmail -> handleUpdateEmail(intent.email) | |
AuthIntent.ChangeEmailIntent.ResendCode -> handleResendCode() | |
AuthIntent.ChangeEmailIntent.BackToLogin -> handleBackToLogin() | |
} | |
} | |
private suspend fun handleUpdateEmail(email: String) { | |
Timber.i("Update email intent received with email: $email") | |
val token = viewModel.sharedPrefs.getString(KEY_TOKEN) ?: "" | |
Timber.d("Token retrieved from shared preferences: $token") | |
requestChangeEmail(token, email, Screen.VerificationChangeEmail) | |
} | |
private suspend fun handleResendCode() { | |
Timber.i("Resend link intent received on VerificationChangeEmail screen.") | |
val token = viewModel.sharedPrefs.getString(KEY_TOKEN) ?: "" | |
Timber.d("Token retrieved from shared preferences for resend link: $token") | |
requestResendOtp(token, Screen.VerificationChangeEmail) | |
} | |
private suspend fun handleBackToLogin() { | |
Timber.i("Back to login intent received from VerificationChangeEmail screen.") | |
viewModel.updateState( | |
viewModel.state.value.copy( | |
loginState = LoginState(), ChangeEmailState = ChangeEmailState() | |
) | |
) | |
navigateToLoginScreen() | |
} | |
private fun navigateToLoginScreen() { | |
viewModel.viewModelScope.launch { | |
viewModel._Auth_uiEvent.emit( | |
ExecuteNavigation( | |
navigationEvent = NavigateTo( | |
screen = Screen.Login, | |
popUpTo = Screen.VerificationChangeEmail, | |
inclusive = true, | |
onDestinationChangedListener = createDestinationChangedListener(Screen.Login) | |
) | |
) | |
) | |
} | |
} | |
private fun requestChangeEmail(token: String, email: String, fromScreen: Screen) { | |
Timber.i("Requesting email change to: $email with token from screen: ${fromScreen.route}") | |
if (!viewModel.networkManager.isNetworkConnected()) { | |
emitSnackbar("Internet Issue", "No Internet Connection") | |
return | |
} | |
viewModel.sharedService.updateEmail("Bearer $token", ResVerifyEmail(email = email)) | |
.enqueue(object : Callback<ResVerifyEmail> { | |
override fun onResponse( | |
call: Call<ResVerifyEmail?>, response: Response<ResVerifyEmail?> | |
) { | |
handleEmailChangeResponse(response, email, fromScreen) | |
} | |
override fun onFailure(call: Call<ResVerifyEmail?>, t: Throwable) { | |
handleRequestFailure( | |
"Email change request failed due to network or server error", t | |
) | |
} | |
}) | |
} | |
private fun handleEmailChangeResponse( | |
response: Response<ResVerifyEmail?>, email: String, fromScreen: Screen | |
) { | |
if (response.isSuccessful && response.body() != null) { | |
Timber.i("Email change successful. Updating shared preferences and state.") | |
viewModel.sharedPrefs.setString(KEY_EMAIL, email) | |
viewModel.updateState( | |
viewModel.state.value.copy( | |
ChangeEmailState = ChangeEmailState(), | |
verificationState = VerificationState(email = email) | |
) | |
) | |
emitSnackbar( | |
"Email Address Updated", | |
"Your email address has been successfully updated, and a verification email has been sent to your new address." | |
) | |
navigateToVerificationScreen(fromScreen) | |
} else if (response.code() == 422) { | |
handleInvalidRequest(response) | |
} | |
} | |
private fun requestResendOtp(token: String, fromScreen: Screen) { | |
Timber.i("Initiating request to resend OTP with token: $token from screen: ${fromScreen.route}") | |
if (!viewModel.networkManager.isNetworkConnected()) { | |
emitSnackbar("Internet Issue", "No Internet Connection") | |
return | |
} | |
viewModel.sharedService.resendVerificationEmail("Bearer $token") | |
.enqueue(object : Callback<ResVerifyEmail> { | |
override fun onResponse( | |
call: Call<ResVerifyEmail?>, response: Response<ResVerifyEmail?> | |
) { | |
handleResendOtpResponse(response, fromScreen) | |
} | |
override fun onFailure(call: Call<ResVerifyEmail?>, t: Throwable) { | |
handleRequestFailure( | |
"Resend OTP request failed due to network or server error", t | |
) | |
} | |
}) | |
} | |
private fun handleResendOtpResponse( | |
response: Response<ResVerifyEmail?>, fromScreen: Screen | |
) { | |
if (response.isSuccessful && response.body() != null) { | |
Timber.i("Verification link successfully resent.") | |
emitSnackbar( | |
"Re-sent Verification Link", | |
"We've re-sent the verification link to your email. Please check your inbox." | |
) | |
navigateToVerificationScreen(fromScreen, inclusive = false) | |
} else if (response.code() == 425) { | |
emitSnackbar( | |
"Re-sent Verification Code failed", | |
"You have to wait 2 minutes before sending another request." | |
) | |
} else { | |
handleGenericFailure(response, "Failed to resend verification link.") | |
} | |
} | |
private fun handleInvalidRequest(response: Response<ResVerifyEmail?>) { | |
Timber.w("Invalid request error code 422.") | |
val errorBody = Gson().fromJson<Message>( | |
response.errorBody()?.string(), Message::class.java | |
) | |
emitSnackbar("Email Address didn't update", errorBody.message) | |
} | |
private fun handleRequestFailure(message: String, throwable: Throwable) { | |
Timber.e("$message: ${throwable.message}") | |
emitSnackbar("Failure", "An error occurred. Please try again later.") | |
} | |
private fun handleGenericFailure(response: Response<ResVerifyEmail?>, defaultMsg: String) { | |
Timber.e("Request failed with error code: ${response.code()}") | |
val errorMsg = response.errorBody()?.string() ?: defaultMsg | |
emitSnackbar("Failure", errorMsg) | |
} | |
private fun emitSnackbar(title: String, message: String) { | |
viewModel.viewModelScope.launch { | |
viewModel._Auth_uiEvent.emit(ShowSnackbar(title, message)) | |
} | |
} | |
private fun navigateToVerificationScreen(fromScreen: Screen, inclusive: Boolean = true) { | |
viewModel.viewModelScope.launch { | |
viewModel._Auth_uiEvent.emit( | |
ExecuteNavigation( | |
navigationEvent = NavigateTo( | |
screen = Screen.Verification, | |
inclusive = inclusive, | |
popUpTo = fromScreen, | |
onDestinationChangedListener = createDestinationChangedListener(Screen.Verification) | |
) | |
) | |
) | |
} | |
} | |
private fun createDestinationChangedListener(destination: Screen) = | |
object : NavController.OnDestinationChangedListener { | |
override fun onDestinationChanged( | |
controller: NavController, destination: NavDestination, arguments: Bundle? | |
) { | |
Timber.d("Navigated to: ${destination.route}") | |
controller.removeOnDestinationChangedListener(this) | |
} | |
} | |
} | |
//************************************************************************************************************************************************ | |
inner class ForgotPasswordHandler(private val viewModel: AuthViewModel) { | |
suspend fun handle(intent: AuthIntent.ForgotPasswordIntent) { | |
when (intent) { | |
AuthIntent.ForgotPasswordIntent.ResendCode -> handleResendCode() | |
is AuthIntent.ForgotPasswordIntent.OnOtpChanged -> handleOtpChanged(intent.otp) | |
is AuthIntent.ForgotPasswordIntent.CheckRemainTime -> handleCheckEnableButton(intent.time) | |
} | |
} | |
private fun handleResendCode() { | |
if (state.value.forgotPasswordState?.timeEnd == false) { | |
emitSnackbar("OTP Error", "The time has been finished.") | |
return | |
} | |
requestResendOtp() | |
} | |
private fun handleOtpChanged(otp: String) { | |
updateVerificationState(otp) | |
if (otp.length == 6) { | |
if (state.value.forgotPasswordState!!.timeEnd == false) { | |
checkOtpVerification(otp) | |
} else { | |
emitSnackbar("OTP Error", "The time has been finished.") | |
} | |
} | |
} | |
private fun updateVerificationState(otp: String) { | |
viewModel.updateState( | |
viewModel.state.value.copy( | |
verificationState = viewModel.state.value.verificationState?.copy( | |
otpCode = otp, isOtpCorrect = otp.length == 6 | |
) ?: VerificationState(otpCode = otp, isOtpCorrect = otp.length == 6) | |
) | |
) | |
} | |
private fun handleCheckEnableButton(time: Int) { | |
state.value.forgotPasswordState?.timeEnd = time <= 0 | |
} | |
private fun checkOtpVerification(otp: String) { | |
viewModel.sharedService.verifyResetPasswordToken(ReqVerifyResetPasswordToken(token = otp)) | |
.enqueue(object : Callback<ResVerifyResetPasswordToken> { | |
override fun onResponse( | |
call: Call<ResVerifyResetPasswordToken?>, | |
response: Response<ResVerifyResetPasswordToken?> | |
) { | |
if (response.isSuccessful && response.body() != null) handleOtpSuccess(otp) | |
else handleOtpError(response.code() == 401) | |
} | |
override fun onFailure(call: Call<ResVerifyResetPasswordToken?>, t: Throwable) { | |
emitSnackbar("Error", "Failed to verify OTP. Please try again.") | |
} | |
}) | |
} | |
private fun handleOtpSuccess(otp: String) { | |
Timber.d("OTP verification successful.") | |
val email = state.value.forgotPasswordState?.email.orEmpty() | |
updateState( | |
state.value.copy( | |
forgotPasswordState = ForgotPasswordState(), | |
resetPasswordState = ResetPasswordState( | |
otp = otp, email = email | |
) | |
) | |
) | |
navigateToNextScreen() | |
} | |
private fun handleOtpError(isUnauthorized: Boolean) { | |
emitSnackbar( | |
if (isUnauthorized) "Invalid OTP" else "Verification Failed", | |
if (isUnauthorized) "The code you entered is incorrect." else "code not valid." | |
) | |
} | |
private fun requestResendOtp() { | |
if (!viewModel.networkManager.isNetworkConnected()) { | |
emitSnackbar("Internet Issue", "No Internet Connection") | |
return | |
} | |
viewModel.sharedService.forgotPassword( | |
ResVerifyEmail(email = state.value.forgotPasswordState!!.email) | |
).enqueue(object : Callback<Unit> { | |
override fun onResponse(call: Call<Unit>, response: Response<Unit>) { | |
if (response.isSuccessful) handleOtpResendSuccess() | |
else handleOtpResendFailure(response.code(), response) | |
} | |
override fun onFailure(call: Call<Unit>, t: Throwable) { | |
emitSnackbar("Action Required", "Something went wrong, please try again.") | |
} | |
}) | |
} | |
private fun handleOtpResendSuccess() { | |
state.value.forgotPasswordState?.apply { | |
timeEnd = false | |
resetTimer = true | |
} | |
emitSnackbar( | |
title = "Re-sent Verification Link", | |
message = "We've re-sent the verification link to your email. Please check your inbox." | |
) | |
} | |
private fun handleOtpResendFailure(code: Int, response: Response<Unit>) { | |
val errorMessage = | |
response.errorBody()?.string() ?: "Failed to resend verification link." | |
emitSnackbar( | |
title = "Re-sent Verification Code failed", | |
message = if (code == 425) "You have to wait 2 minutes before sending another request." | |
else errorMessage | |
) | |
} | |
private fun emitSnackbar(title: String, message: String) { | |
viewModel.viewModelScope.launch { | |
viewModel._Auth_uiEvent.emit(ShowSnackbar(title, message)) | |
} | |
} | |
private fun navigateToNextScreen() { | |
viewModel.viewModelScope.launch { | |
viewModel._Auth_uiEvent.emit( | |
ExecuteNavigation( | |
navigationEvent = NavigateTo( | |
screen = Screen.ResetPassword, | |
inclusive = true, | |
popUpTo = Screen.ForgotPassword, | |
onDestinationChangedListener = createDestinationListener() | |
) | |
) | |
) | |
} | |
} | |
private fun createDestinationListener() = | |
object : NavController.OnDestinationChangedListener { | |
override fun onDestinationChanged( | |
controller: NavController, destination: NavDestination, arguments: Bundle? | |
) { | |
controller.removeOnDestinationChangedListener(this) | |
} | |
} | |
} | |
//************************************************************************************************************************************************ | |
inner class ResetPasswordHandler(private val viewModel: AuthViewModel) { | |
suspend fun handle(intent: AuthIntent.ResetPasswordIntent) { | |
when (intent) { | |
is AuthIntent.ResetPasswordIntent.OnPasswordChanged -> updatePasswordState(intent.password) | |
is AuthIntent.ResetPasswordIntent.OnConfirmPasswordChanged -> updatePasswordConfirmationState( | |
intent.password | |
) | |
is AuthIntent.ResetPasswordIntent.UpdatePassword -> processPasswordUpdate() | |
} | |
} | |
private fun updatePasswordState(password: String) { | |
viewModel.updateState( | |
viewModel.state.value.copy( | |
resetPasswordState = viewModel.state.value.resetPasswordState!!.copy( | |
password = password, startedTyping = true | |
) | |
) | |
) | |
validateResetPasswordForm() | |
} | |
private fun updatePasswordConfirmationState(passwordConfirmation: String) { | |
viewModel.updateState( | |
viewModel.state.value.copy( | |
resetPasswordState = viewModel.state.value.resetPasswordState!!.copy( | |
passwordConfirmation = passwordConfirmation, startedTyping = true | |
) | |
) | |
) | |
validateResetPasswordForm() | |
} | |
private fun processPasswordUpdate() { | |
val currentState = viewModel.state.value.resetPasswordState!! | |
if (!currentState.resetClickable) return | |
if (!viewModel.networkManager.isNetworkConnected()) { | |
emitSnackbar("Internet Issue", "No Internet Connection") | |
return | |
} | |
if (!currentState.formDataValid) { | |
emitSnackbar("Reset Password Failed", currentState.formError) | |
return | |
} | |
val otp = state.value.resetPasswordState!!.otp | |
val resetRequest = ResetPasswordRequest( | |
password = currentState.password, | |
passwordConfirmation = currentState.passwordConfirmation, | |
otp = otp | |
) | |
updateResetPasswordStateLoading(true) | |
viewModel.sharedService.resetPassword(resetRequest) | |
.enqueue(createPasswordUpdateCallback()) | |
} | |
private fun validateResetPasswordForm() { | |
val currentState = viewModel.state.value.resetPasswordState!! | |
val password = currentState.password | |
val passwordConfirmation = currentState.passwordConfirmation | |
val issue = when { | |
password.isEmpty() -> "Password can't be empty" | |
password.length < 8 -> "Password must have at least 8 characters" | |
password != passwordConfirmation -> "Passwords do not match" | |
else -> null | |
} | |
viewModel.updateState( | |
viewModel.state.value.copy( | |
resetPasswordState = currentState.copy( | |
formError = issue.orEmpty(), | |
formDataValid = issue == null, | |
is8Characters = password.length >= 8, | |
passwordsMatch = password == passwordConfirmation | |
) | |
) | |
) | |
} | |
private fun createPasswordUpdateCallback(): Callback<ResetPasswordResponse> { | |
return object : Callback<ResetPasswordResponse> { | |
override fun onResponse( | |
call: Call<ResetPasswordResponse>, response: Response<ResetPasswordResponse> | |
) { | |
handleResetPasswordResponse(response) | |
} | |
override fun onFailure(call: Call<ResetPasswordResponse>, t: Throwable) { | |
emitSnackbar("Reset Failed", t.message.orEmpty()) | |
updateResetPasswordStateLoading(false) | |
} | |
} | |
} | |
private fun handleResetPasswordResponse(response: Response<ResetPasswordResponse>) { | |
val currentState = viewModel.state.value.resetPasswordState!! | |
if (response.isSuccessful && response.body() != null) { | |
resetStateAndNavigateToLogin() | |
} else { | |
emitSnackbar( | |
"Reset Failed", | |
response.errorBody()?.string()?.let(::parseErrorMessage).orEmpty() | |
) | |
} | |
updateResetPasswordStateLoading(false) | |
} | |
private fun resetStateAndNavigateToLogin() { | |
viewModel.updateState( | |
viewModel.state.value.copy(resetPasswordState = ResetPasswordState()) | |
) | |
viewModel.viewModelScope.launch { | |
emitSnackbar("Password Reset", "Your password has been successfully updated.") | |
delay(2000) | |
emitNavigationToLogin() | |
} | |
} | |
private fun updateResetPasswordStateLoading(isLoading: Boolean) { | |
val currentState = viewModel.state.value.resetPasswordState!! | |
viewModel.updateState( | |
viewModel.state.value.copy( | |
resetPasswordState = currentState.copy( | |
isLoading = isLoading, resetClickable = !isLoading | |
) | |
) | |
) | |
} | |
private fun emitSnackbar(title: String, message: String) { | |
viewModel.viewModelScope.launch { | |
viewModel._Auth_uiEvent.emit(ShowSnackbar(title, message)) | |
} | |
} | |
private fun emitNavigationToLogin() { | |
viewModel.viewModelScope.launch { | |
viewModel._Auth_uiEvent.emit( | |
ExecuteNavigation( | |
navigationEvent = NavigateTo( | |
screen = Screen.Login, | |
popUpTo = Screen.ResetPassword, | |
inclusive = true, | |
onDestinationChangedListener = createDestinationListener() | |
) | |
) | |
) | |
} | |
} | |
private fun createDestinationListener() = | |
object : NavController.OnDestinationChangedListener { | |
override fun onDestinationChanged( | |
controller: NavController, destination: NavDestination, arguments: Bundle? | |
) { | |
controller.removeOnDestinationChangedListener(this) | |
} | |
} | |
private fun parseErrorMessage(json: String): String { | |
return Gson().fromJson<Message>(json, Message::class.java).message | |
} | |
} | |
inner class MutualHandler(val viewModel: AuthViewModel) { | |
suspend fun handle(intent: AuthIntent.MutualIntent) { | |
when (intent) { | |
is AuthIntent.MutualIntent.ChangeInsetsVisibility -> { | |
updateState( | |
viewModel.state.value.copy( | |
navigationBarVisibility = !viewModel.state.value.navigationBarVisibility, | |
statusBarVisibility = !viewModel.state.value.statusBarVisibility | |
) | |
) | |
} | |
} | |
} | |
} | |
// Placeholder data classes for API calls (adjust based on actual API requirements) | |
data class ResetPasswordRequest( | |
@SerializedName("new_password") val password: String, | |
@SerializedName("new_password_confirmation") val passwordConfirmation: String, | |
@SerializedName("reset_password_token") val otp: String | |
) | |
data class ResetPasswordResponse( | |
val success: Boolean, val message: String | |
) | |
} | |
sealed class AuthUiEvent { | |
data class ShowSnackbar( | |
val title: String, val message: String | |
) : AuthUiEvent() | |
object AnimateItem : AuthUiEvent() | |
data class ExecuteNavigation(val navigationEvent: NavigationEvent) : AuthUiEvent() | |
object ShowDialog : AuthUiEvent() | |
} | |
@Singleton | |
data class AuthState( | |
var navigationBarVisibility: Boolean = true, | |
var statusBarVisibility: Boolean = true, | |
var signupState: SignupState? = null, | |
var onboardState: OnboardState? = null, | |
var loginState: LoginState? = null, | |
var verificationState: VerificationState? = null, | |
var ChangeEmailState: ChangeEmailState? = null, | |
// var verificationResendEmailState: VerificationResendEmailState? = null, | |
var forgotPasswordState: ForgotPasswordState? = null, | |
var resetPasswordState: ResetPasswordState? = null, | |
) | |
data class LoginState( | |
var password: String = "", | |
var loginClickable: Boolean = false, | |
var email: String = "", | |
var formDataValid: Boolean = false, | |
val isEmailVerified: Boolean = false, | |
var error: String = "", | |
var formError: String = "", | |
var forgetPasswordEmailCorrect: Boolean = false, | |
var loginSuccess: Boolean = false | |
) | |
data class SignupState( | |
var error: String = "", | |
var startedTyping: Boolean = false, | |
var passwordsMatch: Boolean = false, | |
var is8Characters: Boolean = false, | |
var fieldsEmpty: Boolean = true, | |
var isLoadingSignUp: Boolean = false, | |
var formError: String? = null, | |
var formDataValid: Boolean = false, | |
var email: String = (""), | |
var password: String = "", | |
var signupClickable: Boolean = true, | |
var passwordConfirmation: String = "", | |
) | |
data class OnboardState( | |
var userData: UserData? = null, | |
var email: String = "", | |
var password: String = "", | |
var firstName: String = "", | |
var lastName: String = "", | |
var userName: String = "", | |
var error: String = "", | |
var formDataValid: Boolean = false, | |
) | |
data class VerificationState( | |
var otpCode: String = "", | |
var isOtpCorrect: Boolean = false, | |
var otpRemainTime: Long = 0L, | |
var email: String = "", | |
var changingEmailEnabled: Boolean = false, | |
var permitEmailRevision: Boolean = false, | |
) | |
data class ChangeEmailState( | |
var email: String = "", | |
) | |
/*data class VerificationResendEmailState( | |
var email: String = "" | |
)*/ | |
data class ForgotPasswordState( | |
var email: String = "", var timeEnd: Boolean = false, | |
// var canRequestSendOtp: Boolean = false, | |
var resetTimer: Boolean = true, | |
) | |
data class ResetPasswordState( | |
var email: String = "", | |
var otp: String = "", | |
var startedTyping: Boolean = false, | |
var password: String = "", | |
var passwordConfirmation: String = "", | |
var is8Characters: Boolean = false, | |
var passwordsMatch: Boolean = false, | |
var resetClickable: Boolean = true, | |
var formDataValid: Boolean = false, | |
var isLoading: Boolean = false, | |
var formError: String = "", | |
var error: String = "" | |
) | |
sealed class AuthIntent { | |
sealed class SplashIntent : AuthIntent() { | |
object CheckDecision : SplashIntent() | |
} | |
sealed class MutualIntent : AuthIntent() { | |
class ChangeInsetsVisibility(val statusBar: Boolean, navigationBar: Boolean) : | |
MutualIntent() | |
} | |
sealed class SignupIntent : AuthIntent() { | |
data class SignUp( | |
val email: String, val password: String, val passwordConfirmation: String | |
) : SignupIntent() | |
data class OnEmailChanged(var email: String) : SignupIntent() | |
data class OnPasswordChanged(var password: String) : SignupIntent() | |
object SignUpWithGoogle : SignupIntent() | |
data class OnPasswordConfirmationChanged(var passwordConfirmation: String) : SignupIntent() | |
} | |
sealed class LandingIntent : AuthIntent() { | |
object gotoSignup : LandingIntent() | |
object gotoLogin : LandingIntent() | |
} | |
sealed class LoginIntent : AuthIntent() { | |
data class Login(val emailOrUsername: String, val password: String) : LoginIntent() | |
data class OnEmailChanged(val email: String) : LoginIntent() | |
data class OnPasswordChanged(val password: String) : LoginIntent() | |
data class ForgotPassword(val forgotEmail: String) : LoginIntent() | |
data class OnForgotPasswordChanged(val email: String) : LoginIntent() | |
} | |
sealed class OnboardIntent : AuthIntent() { | |
data class Onboard(var firstName: String, var lastName: String, var userName: String) : | |
OnboardIntent() | |
data class OnFirstNameChanged(var firstName: String) : OnboardIntent() | |
data class OnUserNameChanged(var userName: String) : OnboardIntent() | |
data class OnLastNameChanged(var lastName: String) : OnboardIntent() | |
} | |
sealed class VerificationIntent : AuthIntent() { | |
data class OnOtpChanged(val otp: String) : VerificationIntent() | |
data class OnOtpVerifyPressed(val otp: String) : VerificationIntent() | |
//data class GotoVerificationResendCEmail(val email: String) : VerificationIntent() | |
data class GotoChangeEmail(val email: String) : VerificationIntent() | |
object ResendCode : VerificationIntent() | |
data class UpdateTimer(val time: Long) : VerificationIntent() | |
} | |
sealed class ChangeEmailIntent : AuthIntent() { | |
data class UpdateEmail(val email: String) : ChangeEmailIntent() | |
object BackToLogin : ChangeEmailIntent() | |
object ResendCode : ChangeEmailIntent() | |
} | |
/* | |
sealed class VerificationResendEmailIntent : AuthIntent() { | |
data class ChangeEmail(val email: String) : VerificationResendEmailIntent() | |
object ResendCode : VerificationResendEmailIntent() | |
object BackToLogin : VerificationResendEmailIntent() | |
} | |
*/ | |
sealed class ForgotPasswordIntent : AuthIntent() { | |
data class OnOtpChanged(val otp: String) : ForgotPasswordIntent() | |
object ResendCode : ForgotPasswordIntent() | |
data class CheckRemainTime(val time: Int) : ForgotPasswordIntent() | |
} | |
sealed class ResetPasswordIntent : AuthIntent() { | |
data class OnPasswordChanged(val password: String) : ResetPasswordIntent() | |
data class OnConfirmPasswordChanged(val password: String) : ResetPasswordIntent() | |
data class UpdatePassword(val password: String, val passwordConfirmation: String) : | |
ResetPasswordIntent() | |
} | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\viewmodel\BaseViewModel.kt | |
```kt | |
package com.divadventure.viewmodel | |
import androidx.lifecycle.ViewModel | |
import androidx.lifecycle.viewModelScope | |
import com.divadventure.data.navigation.NavigationEvent | |
import kotlinx.coroutines.channels.Channel | |
import kotlinx.coroutines.flow.MutableSharedFlow | |
import kotlinx.coroutines.flow.MutableStateFlow | |
import kotlinx.coroutines.flow.SharedFlow | |
import kotlinx.coroutines.flow.StateFlow | |
import kotlinx.coroutines.flow.asSharedFlow | |
import kotlinx.coroutines.flow.asStateFlow | |
import kotlinx.coroutines.launch | |
abstract class BaseViewModel<Intent, State>(initialState: State) : ViewModel() { | |
private val _state = MutableStateFlow(initialState) | |
val state: StateFlow<State> = _state.asStateFlow() | |
private val _navigationEvent = MutableSharedFlow<NavigationEvent>(replay = 0) | |
val navigationEvent: SharedFlow<NavigationEvent> = _navigationEvent.asSharedFlow() | |
private val intentChannel = Channel<Intent>(Channel.Factory.UNLIMITED) | |
init { | |
viewModelScope.launch { | |
for (intent in intentChannel) { | |
handleIntent(intent) | |
} | |
} | |
} | |
fun sendIntent(intent: Intent) { | |
intentChannel.trySend(intent).isSuccess | |
} | |
fun navigate(event: NavigationEvent) { | |
viewModelScope.launch { | |
_navigationEvent.emit(event) | |
} | |
} | |
fun updateState(newState: State) { | |
_state.value = newState | |
} | |
abstract suspend fun handleIntent(intent: Intent) | |
override fun onCleared() { | |
intentChannel.close() | |
super.onCleared() | |
} | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\viewmodel\HomeViewModel.kt | |
```kt | |
package com.divadventure.viewmodel | |
import android.os.Bundle | |
import androidx.lifecycle.viewModelScope | |
import androidx.navigation.NavController | |
import androidx.navigation.NavController.OnDestinationChangedListener | |
import androidx.navigation.NavDestination | |
import com.divadventure.data.navigation.NavigationEvent | |
import com.divadventure.data.navigation.NavigationEvent.NavigateAdventure | |
import com.divadventure.di.AuthPrefs.VERIFICATION_PASSED_BOOLEAN | |
import com.divadventure.di.SharedPrefs | |
import com.divadventure.di.UserPrefs.KEY_TOKEN | |
import com.divadventure.domain.models.Adventure | |
import com.divadventure.domain.models.AdventureType | |
import com.divadventure.domain.models.Filters | |
import com.divadventure.domain.models.Interest | |
import com.divadventure.domain.models.Meta | |
import com.divadventure.domain.usecase.AdventuresUseCase | |
import com.divadventure.domain.usecase.InterestsUseCase | |
import com.divadventure.domain.usecase.LocationsUseCase | |
import com.divadventure.util.NetworkManager | |
import com.google.android.gms.maps.model.LatLng | |
import com.google.android.libraries.places.api.model.AutocompletePrediction | |
import com.google.android.libraries.places.api.model.Place | |
import dagger.hilt.android.lifecycle.HiltViewModel | |
import kotlinx.coroutines.flow.MutableSharedFlow | |
import kotlinx.coroutines.flow.asSharedFlow | |
import kotlinx.coroutines.launch | |
import timber.log.Timber | |
import java.time.LocalDate | |
import java.time.format.DateTimeFormatter | |
import java.time.format.DateTimeParseException | |
import javax.inject.Inject | |
@HiltViewModel | |
class HomeViewModel @Inject constructor( | |
private val networkManager: NetworkManager, | |
private val sharedPrefs: SharedPrefs, | |
private val locationsUseCase: LocationsUseCase, // Injected use case | |
private val adventuresUseCase: AdventuresUseCase, // Add AdventuresUseCase here | |
private val interestsUseCase: InterestsUseCase // Add InterestsUseCase here | |
) : BaseViewModel<HomeIntent, HomeState>(HomeState()) { | |
private val _uiEvent = MutableSharedFlow<HomeUiEvent>(replay = 0) | |
val uiEvent = _uiEvent.asSharedFlow() | |
override suspend fun handleIntent(intent: HomeIntent) { | |
when (intent) { | |
is HomeIntent.LoadAdventuresData -> { | |
if (isSearch(intent.query)) { | |
executeSearch(intent.query, true) | |
} else if (state.value.group.isNotEmpty() && state.value.group != "ALL") { | |
fetchAdventuresGroup(state.value.group, freshRequest = true) | |
} else { | |
getAllAdventures(true) | |
} | |
} | |
is HomeIntent.LoadMoreAdventuresData -> { | |
val meta = state.value.currentMetadata | |
// Exit early if meta or its nextPage is null | |
if (meta?.nextPage == null || meta.nextPage <= 0) return | |
when (state.value.lastLoadMoreState) { | |
LoadMoreState.NONE -> { | |
// No further action required for NONE state | |
} | |
LoadMoreState.GROUPS -> { | |
// Load more adventures for the selected group | |
fetchAdventuresGroup(state.value.group, freshRequest = false) | |
} | |
LoadMoreState.ALL -> { | |
// Load all adventures | |
getAllAdventures(freshGet = false) | |
} | |
LoadMoreState.SEARCH -> { | |
// Execute search query with fresh results | |
executeSearch(state.value.searchQuery, freshSearch = false) | |
} | |
} | |
} | |
is HomeIntent.Refresh -> { | |
// Handle refresh intent. | |
} | |
is HomeIntent.Logout -> { | |
// Handle logout intent. | |
} | |
is HomeIntent.ClearAllShared -> { | |
sharedPrefs.setString(KEY_TOKEN, null) | |
sharedPrefs.setBoolean(VERIFICATION_PASSED_BOOLEAN, false) | |
} | |
HomeIntent.SwitchShowSearchbar -> { | |
when (state.value.isSearchBarVisible) { | |
true -> { | |
clearFilterSort() | |
state.value.searchAdventuresList = mutableListOf<Adventure>() | |
} | |
false -> { | |
clearFilterSort() | |
} | |
} | |
updateState( | |
state.value.copy(isSearchBarVisible = !state.value.isSearchBarVisible) | |
) | |
} | |
HomeIntent.GotoFilter -> { | |
} | |
HomeIntent.ApplyFilter -> { | |
} | |
HomeIntent.ShowStartDateDialog -> { | |
_uiEvent.emit(HomeUiEvent.ShowStartDateDialog) | |
} | |
HomeIntent.ShowEndDateDialog -> { | |
_uiEvent.emit(HomeUiEvent.ShowEndDateDialog) | |
} | |
is HomeIntent.ApplyEndDate -> { | |
updateState( | |
state.value.copy( | |
filters = state.value.filters.copy(endDate = intent.endDate) | |
) | |
) | |
} | |
is HomeIntent.ApplyInterests -> { | |
updateState( | |
state.value.copy( | |
filters = state.value.filters.copy(interests = intent.interests) | |
) | |
) | |
} | |
is HomeIntent.ApplyLocation -> { | |
updateState( | |
state.value.copy( | |
filters = state.value.filters.copy( | |
locationLAt = intent.lat, locationLng = intent.long | |
) | |
) | |
) | |
} | |
is HomeIntent.ApplyStartDate -> { | |
updateState( | |
state.value.copy( | |
filters = state.value.filters.copy(startDate = intent.startDate) | |
) | |
) | |
} | |
is HomeIntent.ApplyStatus -> { | |
updateState( | |
state.value.copy( | |
filters = state.value.filters.copy(endDate = intent.status) | |
) | |
) | |
} | |
is HomeIntent.LocationFieldChanged -> { | |
updateState( | |
state.value.copy( | |
locationsPredicted = locationsUseCase.predictLocations(intent.location) | |
.toMutableList() | |
) | |
) | |
} | |
is HomeIntent.LocationSelected -> { | |
clearPredictedLocations() | |
val location = locationsUseCase.goLocation(intent.location.placeId) | |
Timber.d("Selected location: $location") | |
updateState( | |
state.value.copy( | |
newLocation = location, | |
locationsPredicted = mutableListOf(), | |
filters = state.value.filters.copy( | |
locationLAt = location?.latLng?.latitude, | |
locationLng = location?.latLng?.longitude | |
) | |
) | |
) | |
} | |
is HomeIntent.SelectGroup -> { | |
when (state.value.isOnCalendar) { | |
true -> { | |
fetchCalendarAdventures( | |
getLaterDate( | |
state.value.startDateFilter, | |
state.value.startDateCalendar | |
) ?: "", | |
getSoonerDate( | |
state.value.endDateFilter, | |
state.value.endDateCalendar | |
) ?: "", | |
group = intent.group | |
) | |
} | |
false -> { | |
if (intent.group.toString() == "ALL") { | |
getAllAdventures(true) | |
} else { | |
fetchAdventuresGroup(intent.group, true) | |
} | |
} | |
} | |
} | |
is HomeIntent.SetEndDate -> { | |
updateState( | |
state.value.copy( | |
filters = state.value.filters.copy(endDate = intent.endDate) | |
) | |
) | |
} | |
is HomeIntent.SetInterests -> { | |
updateState( | |
state.value.copy( | |
filters = state.value.filters.copy(interests = intent.interests) | |
) | |
) | |
} | |
is HomeIntent.SetStartDate -> { | |
updateState( | |
state.value.copy( | |
filters = state.value.filters.copy(startDate = intent.startDate) | |
) | |
) | |
} | |
is HomeIntent.SetStatus -> { | |
updateState( | |
state.value.copy( | |
filters = state.value.filters.copy(state = intent.status) | |
) | |
) | |
} | |
HomeIntent.FetchInterests -> { | |
fetchInterests() | |
} | |
is HomeIntent.ApplySortBy -> { | |
updateState( | |
state.value.copy( | |
filters = state.value.filters.copy(orderBy = intent.sortBy) | |
) | |
) | |
} | |
is HomeIntent.LoadCalendarAdventures -> { | |
if (state.value.isSearchBarVisible) { | |
state.value.startDateFilter = intent.startDate | |
state.value.endDateCalendar = intent.endDate | |
fetchCalendarAdventures( | |
intent.startDate, intent.endDate, state.value.filters | |
) | |
} else { | |
state.value.startDateCalendar = intent.startDate | |
state.value.endDateCalendar = intent.endDate | |
fetchCalendarAdventures( | |
intent.startDate, | |
intent.endDate, | |
group = state.value.group, | |
) | |
} | |
} | |
is HomeIntent.SwitchCalendarColumn -> { | |
when (intent.status) { | |
0 -> { | |
Timber.d("Switching calendar column to non-calendar view") | |
state.value.isOnCalendar = false | |
} | |
1 -> { | |
Timber.d("Switching calendar column to calendar view") | |
state.value.isOnCalendar = true | |
} | |
} | |
} | |
is HomeIntent.MapClicked -> { | |
val location = locationsUseCase.goLocation(intent.location) | |
updateState( | |
state.value.copy( | |
newLocation = location, filters = state.value.filters.copy( | |
locationLAt = intent.location.latitude, | |
locationLng = intent.location.longitude | |
) | |
) | |
) | |
} | |
HomeIntent.ApplyAdevntureType -> { | |
if (state.value.searchAdventuresList.isNullOrEmpty()) { | |
state.value.mainAdventuresList.forEach { | |
it.adventureType = adventuresUseCase.checkAdventureType(it) | |
} | |
} else { | |
state.value.searchAdventuresList.forEach { | |
it.adventureType = adventuresUseCase.checkAdventureType(it) | |
} | |
} | |
} | |
is HomeIntent.HandleAdventureClick -> { | |
val adventure = intent.adventure | |
when (adventure.adventureType) { | |
AdventureType.Join -> { | |
if (adventure.joinRequestNeeded) { | |
updateAdventureState(adventure.id, AdventureType.Pending) | |
} else { | |
// As per issue: "if join request need is false , it should change to Leave" | |
updateAdventureState(adventure.id, AdventureType.Leave) | |
// Potentially, this could also mean remove it immediately if it becomes "Leave" | |
// removeAdventureFromLists(adventure.id) // Decided to keep it visible as "Leave" first | |
} | |
} | |
AdventureType.Going -> { | |
updateState( | |
state.value.copy( | |
showGoingBottomSheet = true, | |
selectedAdventureForBottomSheet = adventure | |
) | |
) | |
} | |
AdventureType.Pending -> { | |
updateAdventureState(adventure.id, AdventureType.Join) | |
} | |
AdventureType.Leave -> { | |
// When a "Leave" button is clicked (originally it was "Leave") | |
removeAdventureFromLists(adventure.id) | |
} | |
AdventureType.Manage -> { | |
// This case should ideally not be sent to HomeViewModel via HandleAdventureClick | |
// as MainViewModel handles navigation for Manage. Log if it occurs. | |
Timber.w("HandleAdventureClick received for Manage type: ${adventure.id}") | |
manageAdventure(adventure) | |
} | |
null -> { | |
// Handle null case if necessary, perhaps log an error or default behavior | |
Timber.e("AdventureType is null for adventure: ${adventure.id}") | |
} | |
} | |
} | |
is HomeIntent.HandleBottomSheetAction -> { | |
val adventureToActOn = state.value.selectedAdventureForBottomSheet | |
if (adventureToActOn == null || adventureToActOn.id != intent.adventureId) { | |
// Defensive check | |
updateState(state.value.copy(showGoingBottomSheet = false, selectedAdventureForBottomSheet = null)) | |
return | |
} | |
when (intent.action) { | |
"Yes" -> { // Clicked "Yes" on "Going" bottom sheet | |
updateAdventureState(adventureToActOn.id, AdventureType.Leave) | |
} | |
"No" -> { // Clicked "No" on "Going" bottom sheet | |
removeAdventureFromLists(adventureToActOn.id) | |
} | |
"Maybe" -> { | |
// Just close the bottom sheet, do nothing else to the adventure item | |
} | |
} | |
updateState(state.value.copy(showGoingBottomSheet = false, selectedAdventureForBottomSheet = null)) | |
} | |
is HomeIntent.DismissBottomSheet -> { | |
updateState(state.value.copy(showGoingBottomSheet = false, selectedAdventureForBottomSheet = null)) | |
} | |
} | |
} | |
private fun manageAdventure(adventure: Adventure) { | |
// Proceed with existing navigation logic for Manage | |
viewModelScope.launch { | |
try { | |
val safeAdventure = adventure.copy( | |
adventureRequest = adventure.adventureRequest ?: emptyList(), | |
adventurers = adventure.adventurers.ifEmpty { emptyList() }) | |
_uiEvent.emit( | |
HomeUiEvent.NavigateToNextScreen( | |
NavigateAdventure( | |
adventure = safeAdventure, | |
onDestinationChangedListener = object : OnDestinationChangedListener { | |
override fun onDestinationChanged( | |
controller: NavController, | |
destination: NavDestination, | |
arguments: Bundle? | |
) { | |
// Implement the required behavior here if needed | |
} | |
}, | |
removeListenerAfter = true | |
) | |
) | |
) | |
} catch (e: Exception) { | |
e.printStackTrace() | |
} | |
} | |
} | |
private fun updateAdventureState(adventureId: String, newType: AdventureType) { | |
val currentMainList = state.value.mainAdventuresList.toMutableList() | |
val mainIndex = currentMainList.indexOfFirst { it.id == adventureId } | |
if (mainIndex != -1) { | |
currentMainList[mainIndex] = currentMainList[mainIndex].copy(adventureType = newType) | |
} | |
val currentSearchList = state.value.searchAdventuresList.toMutableList() | |
val searchIndex = currentSearchList.indexOfFirst { it.id == adventureId } | |
if (searchIndex != -1) { | |
currentSearchList[searchIndex] = currentSearchList[searchIndex].copy(adventureType = newType) | |
} | |
updateState(state.value.copy(mainAdventuresList = currentMainList, searchAdventuresList = currentSearchList)) | |
} | |
private fun removeAdventureFromLists(adventureId: String) { | |
val currentMainList = state.value.mainAdventuresList.toMutableList() | |
currentMainList.removeAll { it.id == adventureId } | |
val currentSearchList = state.value.searchAdventuresList.toMutableList() | |
currentSearchList.removeAll { it.id == adventureId } | |
updateState(state.value.copy(mainAdventuresList = currentMainList, searchAdventuresList = currentSearchList)) | |
} | |
private fun getLaterDate(date1String: String, date2String: String): String? { | |
val formatter = DateTimeFormatter.ISO_LOCAL_DATE // Corresponds to "yyyy-MM-dd" | |
try { | |
val date1 = LocalDate.parse(date1String, formatter) | |
val date2 = LocalDate.parse(date2String, formatter) | |
return if (date1.isAfter(date2)) { | |
date1String | |
} else { | |
date2String | |
} | |
} catch (e: DateTimeParseException) { | |
Timber.e(e, "Error parsing dates: $date1String, $date2String") | |
// Handle cases where one or both dates might be invalid | |
try { | |
LocalDate.parse(date1String, formatter) | |
return date1String // date1 is valid, date2 was not | |
} catch (e1: DateTimeParseException) { | |
try { | |
LocalDate.parse(date2String, formatter) | |
return date2String // date2 is valid, date1 was not | |
} catch (e2: DateTimeParseException) { | |
return null // Both are invalid | |
} | |
} | |
} | |
} | |
private fun getSoonerDate(date1String: String, date2String: String): String? { | |
val formatter = DateTimeFormatter.ISO_LOCAL_DATE // Corresponds to "yyyy-MM-dd" | |
try { | |
val date1 = LocalDate.parse(date1String, formatter) | |
val date2 = LocalDate.parse(date2String, formatter) | |
return if (date1.isBefore(date2)) { | |
date1String | |
} else { | |
date2String | |
} | |
} catch (e: DateTimeParseException) { | |
Timber.e(e, "Error parsing dates: $date1String, $date2String") | |
// Handle cases where one or both dates might be invalid | |
// This basic version returns null if any date is invalid. | |
// You might want to return the valid one if the other is invalid. | |
try { | |
LocalDate.parse(date1String, formatter) | |
return date1String // date1 is valid, date2 was not | |
} catch (e1: DateTimeParseException) { | |
try { | |
LocalDate.parse(date2String, formatter) | |
return date2String // date2 is valid, date1 was not | |
} catch (e2: DateTimeParseException) { | |
return null // Both are invalid | |
} | |
} | |
} | |
} | |
private fun clearFilterSort() { | |
state.value.filters = Filters() | |
} | |
private suspend fun fetchCalendarAdventures( | |
startDate: String, endDate: String, filters: Filters? = null, group: String = "" | |
) { | |
val updatedState = state.value.copy( | |
isLoading = state.value.isLoading.copy(calendarIsLoading = true), | |
// filters = state.value.filters.copy(startDate = startDate, endDate = endDate), | |
group = group.replaceCreated() | |
) | |
if (state.value.isSearchBarVisible) { | |
updateState(updatedState.copy(searchAdventuresList = mutableListOf())) | |
} else { | |
updateState(updatedState.copy(mainAdventuresList = mutableListOf())) | |
} | |
if (!networkManager.isNetworkConnected()) { // Handle network checks | |
_uiEvent.tryEmit(HomeUiEvent.ShowSnackbar("Error", "No network connection")) | |
return | |
} | |
adventuresUseCase.fetchCalendarAdventures(group, startDate, endDate) | |
.onSuccess { adventures -> | |
// state.value.adventuresList.addAll(adventures.adventures) | |
if (state.value.isSearchBarVisible) { | |
updateState( | |
state.value.copy( | |
currentMetadata = adventures.meta, | |
isLoading = state.value.isLoading.copy(calendarIsLoading = false), | |
searchAdventuresList = adventures.adventures.toMutableList() | |
) | |
) | |
} else { | |
updateState( | |
state.value.copy( | |
currentMetadata = adventures.meta, | |
isLoading = state.value.isLoading.copy(calendarIsLoading = false), | |
mainAdventuresList = adventures.adventures.toMutableList() | |
) | |
) | |
} | |
}.onFailure { exception -> | |
updateState( | |
state.value.copy( | |
isLoading = state.value.isLoading.copy(calendarIsLoading = false) | |
) | |
) | |
_uiEvent.tryEmit( | |
HomeUiEvent.ShowSnackbar( | |
"Error", exception.localizedMessage ?: "Failed to load calendar adventures" | |
) | |
) | |
} | |
} | |
private fun isSearch(query: String?): Boolean { | |
val state = state.value | |
// return query != null || state.filters.state != null || state.filters.interests.isNullOrEmpty() == false || state.filters.startDate != null || state.filters.endDate != null || state.filters.locationLAt != null || state.filters.locationLng != null | |
return state.isSearchBarVisible | |
} | |
private suspend fun fetchInterests() { | |
state.value.allInterests.clear() | |
if (!networkManager.isNetworkConnected()) { // Handle network checks | |
_uiEvent.emit(HomeUiEvent.ShowSnackbar("Error", "No network connection")) | |
return | |
} | |
updateState(state.value.copy(isLoading = state.value.isLoading.copy(interestsIsLoading = true))) // Update loading state | |
interestsUseCase.fetchInterests().onSuccess { interests -> | |
updateState(state.value.copy(isLoading = state.value.isLoading.copy(interestsIsLoading = false))) | |
state.value.allInterests.clear() | |
updateState(state.value) | |
state.value.allInterests.addAll(interests.interests) | |
updateState(state.value.copy(allInterests = interests.interests.toMutableList())) | |
}.onFailure { exception -> | |
updateState(state.value.copy(isLoading = state.value.isLoading.copy(interestsIsLoading = false))) | |
} | |
} | |
private fun String.replaceCreated(): String { | |
return this.replace("Created", "Owned") | |
} | |
private suspend fun fetchAdventuresGroup( | |
group: String, | |
freshRequest: Boolean = false, | |
) { | |
if (!networkManager.isNetworkConnected()) { // Handle network checks | |
_uiEvent.emit(HomeUiEvent.ShowSnackbar("Error", "No network connection")) | |
return | |
} | |
if (freshRequest) updateState( | |
state.value.copy( | |
isLoading = state.value.isLoading.copy(adventuresLoading = true), | |
group = group, | |
lastLoadMoreState = LoadMoreState.GROUPS | |
) | |
) // Update loading state | |
else updateState( | |
state.value.copy( | |
isLoading = state.value.isLoading.copy(isLoadingMore = true), | |
group = group, | |
lastLoadMoreState = LoadMoreState.GROUPS | |
) | |
) // Update loading state | |
adventuresUseCase.fetchGroupedAdventures( | |
group.replaceCreated(), | |
if (freshRequest) 1 else state.value.currentMetadata?.nextPage ?: 1 | |
).onSuccess { adventures -> | |
if (freshRequest) { | |
state.value.mainAdventuresList.clear() | |
updateState(state.value) | |
} | |
state.value.mainAdventuresList.addAll(adventures.adventures) | |
updateState( | |
state.value.copy( | |
currentMetadata = adventures.meta, isLoading = state.value.isLoading.copy( | |
adventuresLoading = false, isLoadingMore = false | |
) | |
) | |
) | |
}.onFailure { exception -> | |
_uiEvent.emit( | |
HomeUiEvent.ShowSnackbar( | |
"Error", exception.localizedMessage ?: "Unknown error" | |
) | |
) | |
updateState( | |
state.value.copy( | |
isLoading = state.value.isLoading.copy( | |
adventuresLoading = false, isLoadingMore = false | |
) | |
) | |
) | |
} | |
} | |
private suspend fun getAllAdventures( | |
freshGet: Boolean = false, | |
) { | |
if (!networkManager.isNetworkConnected()) { // Handle network checks | |
_uiEvent.emit(HomeUiEvent.ShowSnackbar("Error", "No network connection")) | |
return | |
} | |
// Check if we're already in ALL mode or switching to it | |
val wasAlreadyInAllMode = state.value.group.isEmpty() || state.value.group == "ALL" | |
if (freshGet) { | |
updateState( | |
state.value.copy( | |
isLoading = state.value.isLoading.copy(adventuresLoading = true), | |
lastLoadMoreState = LoadMoreState.ALL, | |
group = "ALL" | |
) | |
) // Update loading state | |
} else { | |
updateState( | |
state.value.copy( | |
isLoading = state.value.isLoading.copy(isLoadingMore = true), | |
lastLoadMoreState = LoadMoreState.ALL, | |
group = "ALL" | |
) | |
) // Update loading state | |
} | |
adventuresUseCase.getAllAdventures( | |
if (freshGet) 1 else state.value.currentMetadata?.nextPage ?: 1 | |
).onSuccess { adventures -> | |
if (freshGet) { | |
if (!wasAlreadyInAllMode) { | |
// If we switched to ALL mode, clear the list | |
state.value.mainAdventuresList.clear() | |
updateState(state.value) | |
state.value.mainAdventuresList.addAll(adventures.adventures) | |
} else { | |
// If we were already in ALL mode, add new items at the start | |
val newList = adventures.adventures.toMutableList() | |
newList.addAll(state.value.mainAdventuresList) | |
state.value.mainAdventuresList.clear() | |
state.value.mainAdventuresList.addAll(newList) | |
} | |
} else { | |
// For load more (pagination), just append to the end | |
state.value.mainAdventuresList.addAll(adventures.adventures) | |
} | |
updateState( | |
state.value.copy( | |
currentMetadata = adventures.meta, isLoading = state.value.isLoading.copy( | |
adventuresLoading = false, isLoadingMore = false | |
) | |
) | |
) | |
// updateState(state.value) | |
}.onFailure { exception -> | |
updateState( | |
state.value.copy( | |
isLoading = state.value.isLoading.copy( | |
adventuresLoading = false, isLoadingMore = false | |
) | |
) | |
) | |
_uiEvent.emit( | |
HomeUiEvent.ShowSnackbar( | |
"Error", exception.localizedMessage ?: "Unknown error" | |
) | |
) | |
} | |
} | |
private suspend fun executeSearch(query: String?, freshSearch: Boolean = false) { | |
if (!networkManager.isNetworkConnected()) { // Handle network checks | |
_uiEvent.emit(HomeUiEvent.ShowSnackbar("Error", "No network connection")) | |
return | |
} | |
// Check if the search query changed | |
val queryChanged = state.value.searchQuery != query | |
if (freshSearch) updateState( | |
state.value.copy( | |
isLoading = state.value.isLoading.copy(adventuresLoading = true), | |
searchQuery = query, | |
lastLoadMoreState = LoadMoreState.SEARCH | |
) | |
) // Update loading state | |
else updateState( | |
state.value.copy( | |
isLoading = state.value.isLoading.copy(isLoadingMore = true), | |
searchQuery = query, | |
lastLoadMoreState = LoadMoreState.SEARCH | |
) | |
) | |
adventuresUseCase.search( | |
query, | |
if (freshSearch == false) state.value.currentMetadata?.nextPage ?: 1 else 1, | |
state.value.filters | |
).onSuccess { adventures -> | |
if (freshSearch) { | |
if (queryChanged) { | |
// If query changed, clear the list and update with new data | |
state.value.searchAdventuresList.clear() | |
updateState(state.value) | |
state.value.searchAdventuresList.addAll(adventures.adventures) | |
} else { | |
// If it's the same query, add new elements at the start of the list | |
val newList = adventures.adventures.toMutableList() | |
newList.addAll(state.value.searchAdventuresList) | |
state.value.searchAdventuresList.clear() | |
state.value.searchAdventuresList.addAll(newList) | |
} | |
} else { | |
// For load more (pagination), just append to the end | |
state.value.searchAdventuresList.addAll(adventures.adventures) | |
} | |
updateState( | |
state.value.copy( | |
currentMetadata = adventures.meta, isLoading = state.value.isLoading.copy( | |
adventuresLoading = false, | |
isLoadingMore = false | |
) | |
) | |
) | |
// Handle success, e.g., update state or navigate | |
_uiEvent.emit(HomeUiEvent.ShowSnackbar("Success", "Search completed")) | |
}.onFailure { exception -> | |
updateState( | |
state.value.copy( | |
isLoading = state.value.isLoading.copy( | |
adventuresLoading = false, isLoadingMore = false | |
) | |
) | |
) | |
_uiEvent.emit( | |
HomeUiEvent.ShowSnackbar( | |
"Error", exception.localizedMessage ?: "Unknown error" | |
) | |
) | |
} | |
} | |
private fun clearPredictedLocations() { | |
updateState( | |
state.value.copy( | |
locationsPredicted = mutableListOf<AutocompletePrediction>() | |
) | |
) | |
} | |
} | |
sealed class HomeIntent { | |
data class LoadAdventuresData(val query: String?) : HomeIntent() | |
object LoadMoreAdventuresData : HomeIntent() | |
object Refresh : HomeIntent() | |
object Logout : HomeIntent() | |
data class SwitchCalendarColumn(val status: Int) : HomeIntent() | |
data class LoadCalendarAdventures(val startDate: String, val endDate: String) : HomeIntent() | |
object ClearAllShared : HomeIntent() | |
object SwitchShowSearchbar : HomeIntent() | |
object GotoFilter : HomeIntent() | |
data class ApplySortBy(val sortBy: String) : HomeIntent() | |
object ApplyFilter : HomeIntent() | |
data class LocationSelected(val location: AutocompletePrediction) : HomeIntent() | |
object ShowStartDateDialog : HomeIntent() | |
object ShowEndDateDialog : HomeIntent() | |
data class ApplyInterests(val interests: MutableList<Interest>) : HomeIntent() | |
data class ApplyStatus(val status: String) : HomeIntent() | |
data class ApplyStartDate(val startDate: String) : HomeIntent() | |
data class ApplyLocation(val lat: Double, val long: Double) : HomeIntent() | |
data class ApplyEndDate(val endDate: String) : HomeIntent() | |
data class LocationFieldChanged(val location: String) : HomeIntent() | |
object ApplyAdevntureType : HomeIntent() | |
data class MapClicked(val location: LatLng) : HomeIntent() | |
// data class ExecuteSearch(val query: String) : HomeIntent() | |
data class SelectGroup(val group: String) : HomeIntent() | |
object FetchInterests : HomeIntent() | |
// set Filters | |
data class SetStartDate(val startDate: String) : HomeIntent() | |
data class SetEndDate(val endDate: String) : HomeIntent() | |
data class SetInterests(val interests: MutableList<Interest>) : HomeIntent() | |
data class SetStatus(val status: String?) : HomeIntent() | |
// New Intents for adventure click and bottom sheet | |
data class HandleAdventureClick(val adventure: Adventure) : HomeIntent() | |
data class HandleBottomSheetAction(val action: String, val adventureId: String?) : HomeIntent() | |
object DismissBottomSheet : HomeIntent() | |
} | |
data class HomeState( | |
var searchQuery: String? = "", | |
var lastLoadMoreState: LoadMoreState = LoadMoreState.NONE, | |
var currentMetadata: Meta? = null, | |
var filters: Filters = Filters(), | |
var group: String = "", | |
var isOnCalendar: Boolean = false, | |
var isSearchBarVisible: Boolean = false, | |
var isLoading: IsLoading = IsLoading(), | |
var mainAdventuresList: MutableList<Adventure> = mutableListOf(), | |
var searchAdventuresList: MutableList<Adventure> = mutableListOf(), | |
val showGoingBottomSheet: Boolean = false, | |
val selectedAdventureForBottomSheet: Adventure? = null, | |
var locationsPredicted: MutableList<AutocompletePrediction> = mutableListOf(), | |
var newLocation: Place? = null, | |
var allInterests: MutableList<Interest> = mutableListOf(), | |
// var selectedInterests: MutableList<Interest> = mutableListOf(), | |
var startDateFilter: String = "", | |
var endDateFilter: String = "", | |
var startDateCalendar: String = "", | |
var endDateCalendar: String = "" | |
) | |
data class IsLoading( | |
var searchIsLoading: Boolean = false, | |
var locationIsLoading: Boolean = false, | |
var calendarIsLoading: Boolean = false, | |
var interestsIsLoading: Boolean = false, | |
var adventuresLoading: Boolean = false, | |
var isLoadingMore: Boolean = false | |
) | |
sealed class HomeUiEvent() { | |
data class ShowSnackbar( | |
val title: String, val message: String | |
) : HomeUiEvent() | |
object AnimateItem : HomeUiEvent() | |
data class NavigateToNextScreen(val navigationEvent: NavigationEvent) : HomeUiEvent() | |
object ShowStartDateDialog : HomeUiEvent() | |
object ShowEndDateDialog : HomeUiEvent() | |
data class ShowDim(val show: Boolean) : HomeUiEvent() | |
} | |
enum class LoadMoreState { | |
NONE, GROUPS, ALL, SEARCH | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\viewmodel\MainViewModel.kt | |
```kt | |
package com.divadventure.viewmodel | |
import android.os.Bundle | |
import androidx.lifecycle.viewModelScope | |
import androidx.navigation.NavController | |
import androidx.navigation.NavController.OnDestinationChangedListener | |
import androidx.navigation.NavDestination | |
import com.divadventure.data.navigation.NavigationEvent | |
import com.divadventure.data.navigation.NavigationEvent.NavigateAdventure | |
import com.divadventure.data.navigation.NavigationEvent.NavigateProfile | |
import com.divadventure.data.navigation.NavigationEvent.NavigateTo | |
import com.divadventure.data.navigation.NavigationEvent.PopBackStack | |
import com.divadventure.data.navigation.Screen | |
import com.divadventure.di.AuthPrefs.VERIFICATION_PASSED_BOOLEAN | |
import com.divadventure.di.SharedPrefs | |
import com.divadventure.di.UserPrefs.KEY_TOKEN | |
import com.divadventure.domain.models.Adventure | |
import com.divadventure.domain.models.AdventureType | |
import com.divadventure.domain.models.Friend | |
import com.divadventure.util.NetworkManager | |
import com.divadventure.viewmodel.MainUiEvent.NavigateToNextScreen | |
import dagger.hilt.android.lifecycle.HiltViewModel | |
import kotlinx.coroutines.flow.MutableSharedFlow | |
import kotlinx.coroutines.flow.asSharedFlow | |
import kotlinx.coroutines.launch | |
import javax.inject.Inject | |
@HiltViewModel | |
class MainViewModel @Inject constructor( | |
private val networkManager: NetworkManager, | |
private val sharedPrefs: SharedPrefs | |
) : BaseViewModel<MainIntent, MainState>(MainState()) { | |
private val _uiEvent = MutableSharedFlow<MainUiEvent>(replay = 0) | |
val uiEvent = _uiEvent.asSharedFlow() | |
override suspend fun handleIntent(intent: MainIntent) { | |
when (intent) { | |
is MainIntent.LoadData -> { | |
updateState(state.value.copy(isLoading = true)) | |
} | |
is MainIntent.Refresh -> { | |
// Handle refresh intent. | |
} | |
is MainIntent.logout -> { | |
// Handle logout intent. | |
} | |
is MainIntent.clearAllShared -> { | |
sharedPrefs.setString(KEY_TOKEN, null) | |
sharedPrefs.setBoolean(VERIFICATION_PASSED_BOOLEAN, false) | |
} | |
is MainIntent.ShowDim -> { | |
} | |
MainIntent.GotoFilter -> { | |
viewModelScope.launch { | |
// Ensure you only navigate if explicitly needed to prevent auto-navigation issues | |
_uiEvent.emit( | |
NavigateToNextScreen( | |
NavigateTo( | |
screen = Screen.Filter, | |
popUpTo = null, | |
inclusive = false, | |
onDestinationChangedListener = object : | |
OnDestinationChangedListener { | |
override fun onDestinationChanged( | |
controller: NavController, | |
destination: NavDestination, | |
arguments: Bundle? | |
) { | |
// Implement the required behavior here if needed | |
} | |
}, | |
singleTop = true, | |
removeListenerAfter = true | |
) | |
) | |
) | |
} | |
} | |
MainIntent.GotoNotifications -> { | |
viewModelScope.launch { | |
_uiEvent.emit( | |
NavigateToNextScreen( | |
NavigateTo( | |
screen = Screen.Notifications, | |
popUpTo = null, | |
inclusive = false, | |
onDestinationChangedListener = object : | |
OnDestinationChangedListener { | |
override fun onDestinationChanged( | |
controller: NavController, | |
destination: NavDestination, | |
arguments: Bundle? | |
) { | |
// Implement the required behavior here if needed | |
} | |
}, | |
singleTop = true, | |
removeListenerAfter = true | |
) | |
) | |
) | |
} | |
} | |
MainIntent.PopBackStack -> { | |
viewModelScope.launch { | |
_uiEvent.emit( | |
NavigateToNextScreen( | |
PopBackStack | |
) | |
) | |
} | |
} | |
MainIntent.GotoSortBy -> { | |
viewModelScope.launch { | |
// Ensure you only navigate if explicitly needed to prevent auto-navigation issues | |
_uiEvent.emit( | |
NavigateToNextScreen( | |
NavigateTo( | |
screen = Screen.SortBy, | |
popUpTo = null, | |
inclusive = false, | |
onDestinationChangedListener = object : | |
OnDestinationChangedListener { | |
override fun onDestinationChanged( | |
controller: NavController, | |
destination: NavDestination, | |
arguments: Bundle? | |
) { | |
// Implement the required behavior here if needed | |
} | |
}, | |
singleTop = true, | |
removeListenerAfter = true | |
) | |
) | |
) | |
} | |
} | |
MainIntent.GotoInterests -> { | |
viewModelScope.launch { | |
_uiEvent.emit( | |
NavigateToNextScreen( | |
NavigateTo( | |
screen = Screen.Interests, | |
popUpTo = null, | |
inclusive = false, | |
onDestinationChangedListener = object : | |
OnDestinationChangedListener { | |
override fun onDestinationChanged( | |
controller: NavController, | |
destination: NavDestination, | |
arguments: Bundle? | |
) { | |
// Implement the required behavior here if needed | |
} | |
}, | |
singleTop = true, | |
removeListenerAfter = true | |
) | |
) | |
) | |
} | |
} | |
MainIntent.GotoStatus -> { | |
viewModelScope.launch { | |
_uiEvent.emit( | |
NavigateToNextScreen( | |
NavigateTo( | |
screen = Screen.Status, | |
popUpTo = null, | |
inclusive = false, | |
onDestinationChangedListener = object : | |
OnDestinationChangedListener { | |
override fun onDestinationChanged( | |
controller: NavController, | |
destination: NavDestination, | |
arguments: Bundle? | |
) { | |
// Implement the required behavior here if needed | |
} | |
}, | |
singleTop = true, | |
removeListenerAfter = true | |
) | |
) | |
) | |
} | |
} | |
MainIntent.GotoLocation -> { | |
viewModelScope.launch { | |
_uiEvent.emit( | |
NavigateToNextScreen( | |
NavigateTo( | |
screen = Screen.Location, | |
popUpTo = null, | |
inclusive = false, | |
onDestinationChangedListener = object : | |
OnDestinationChangedListener { | |
override fun onDestinationChanged( | |
controller: NavController, | |
destination: NavDestination, | |
arguments: Bundle? | |
) { | |
// Implement the required behavior here if needed | |
} | |
}, | |
singleTop = true, | |
removeListenerAfter = true | |
) | |
) | |
) | |
} | |
} | |
is MainIntent.GotoProfile -> { | |
viewModelScope.launch { | |
_uiEvent.emit( | |
NavigateToNextScreen( | |
NavigateProfile( | |
intent.profileId, | |
onDestinationChangedListener = object : | |
OnDestinationChangedListener { | |
override fun onDestinationChanged( | |
controller: NavController, | |
destination: NavDestination, | |
arguments: Bundle? | |
) { | |
// Implement the required behavior here if needed | |
} | |
}, true | |
) | |
) | |
) | |
} | |
} | |
MainIntent.GoNotificationsSettings -> { | |
_uiEvent.emit( | |
NavigateToNextScreen( | |
NavigateTo( | |
Screen.NotificationsSettings, | |
onDestinationChangedListener = | |
object : | |
OnDestinationChangedListener { | |
override fun onDestinationChanged( | |
controller: NavController, | |
destination: NavDestination, | |
arguments: Bundle? | |
) { | |
} | |
}, | |
popUpTo = null, | |
singleTop = true, | |
removeListenerAfter = true, | |
inclusive = true | |
), | |
) | |
) | |
} | |
MainIntent.GoPrivacySettings -> { | |
_uiEvent.emit( | |
NavigateToNextScreen( | |
NavigateTo( | |
Screen.PrivacySettings, | |
onDestinationChangedListener = | |
object : | |
OnDestinationChangedListener { | |
override fun onDestinationChanged( | |
controller: NavController, | |
destination: NavDestination, | |
arguments: Bundle? | |
) { | |
} | |
}, | |
popUpTo = null, | |
singleTop = true, | |
removeListenerAfter = true, | |
inclusive = true | |
), | |
) | |
) | |
} | |
MainIntent.GoAccountSettings -> { | |
_uiEvent.emit( | |
NavigateToNextScreen( | |
NavigateTo( | |
Screen.AccountSettings, | |
onDestinationChangedListener = | |
object : | |
OnDestinationChangedListener { | |
override fun onDestinationChanged( | |
controller: NavController, | |
destination: NavDestination, | |
arguments: Bundle? | |
) { | |
} | |
}, | |
popUpTo = null, | |
singleTop = true, | |
removeListenerAfter = true, | |
inclusive = true | |
), | |
) | |
) | |
} | |
is MainIntent.OnSelectAdventure -> { | |
val adventure = intent.adventure | |
if (adventure.adventureType == AdventureType.Manage) { | |
// Proceed with existing navigation logic for Manage | |
viewModelScope.launch { | |
try { | |
val safeAdventure = intent.adventure.copy( | |
adventureRequest = intent.adventure.adventureRequest ?: emptyList(), | |
adventurers = intent.adventure.adventurers.ifEmpty { emptyList() } | |
) | |
_uiEvent.emit( | |
NavigateToNextScreen( | |
NavigateAdventure( | |
adventure = safeAdventure, | |
onDestinationChangedListener = object : OnDestinationChangedListener { | |
override fun onDestinationChanged( | |
controller: NavController, | |
destination: NavDestination, | |
arguments: Bundle? | |
) { | |
// Implement the required behavior here if needed | |
} | |
}, | |
removeListenerAfter = true | |
) | |
) | |
) | |
} catch (e: Exception) { | |
e.printStackTrace() | |
} | |
} | |
} else { | |
// For other types, emit the new event to be handled by Home screen/HomeViewModel | |
viewModelScope.launch { | |
_uiEvent.emit(MainUiEvent.AdventureAction(adventure)) | |
} | |
} | |
} | |
is MainIntent.GoOwnerParticipantMenu -> { | |
viewModelScope.launch { | |
try { | |
// Ensure the adventure has initialized collections to prevent serialization issues | |
val safeAdventure = intent.adventure.copy( | |
adventureRequest = intent.adventure.adventureRequest | |
?: emptyList(), | |
adventurers = intent.adventure.adventurers.ifEmpty { emptyList() } | |
) | |
_uiEvent.emit( | |
NavigateToNextScreen( | |
NavigationEvent.NavigateAdventureOwnerParticipantMenu( | |
adventure = safeAdventure, | |
onDestinationChangedListener = object : | |
OnDestinationChangedListener { | |
override fun onDestinationChanged( | |
controller: NavController, | |
destination: NavDestination, | |
arguments: Bundle? | |
) { | |
// Implement the required behavior here if needed | |
} | |
}, | |
removeListenerAfter = true | |
) | |
) | |
) | |
} catch (e: Exception) { | |
// Log the error but prevent app crash | |
e.printStackTrace() | |
} | |
} | |
} | |
} | |
} | |
} | |
sealed class MainIntent { | |
data class ShowDim(val boolean: Boolean) : MainIntent() | |
object LoadData : MainIntent() | |
object Refresh : MainIntent() | |
object logout : MainIntent() | |
object clearAllShared : MainIntent() | |
object GotoFilter : MainIntent() | |
object GotoSortBy : MainIntent() | |
object GotoNotifications : MainIntent() | |
object PopBackStack : MainIntent() | |
object GotoInterests : MainIntent() | |
object GotoStatus : MainIntent() | |
object GoNotificationsSettings : MainIntent() | |
object GoPrivacySettings : MainIntent() | |
object GotoLocation : MainIntent() | |
data class OnSelectAdventure(val adventure: Adventure) : MainIntent() | |
data class GoOwnerParticipantMenu(val adventure: Adventure) : MainIntent() | |
object GoAccountSettings : MainIntent() | |
data class GotoProfile(val profileId: String) : MainIntent() | |
} | |
data class MainState( | |
val isLoading: Boolean = false, | |
var friends: MutableList<Friend> = mutableListOf<Friend>(), | |
var currentAdventure: Adventure? = null, | |
) | |
sealed class MainUiEvent() { | |
data class ShowSnackbar( | |
val title: String, val message: String | |
) : MainUiEvent() | |
object AnimateItem : MainUiEvent() | |
data class NavigateToNextScreen(val navigationEvent: NavigationEvent) : MainUiEvent() | |
object ShowDialog : MainUiEvent() | |
data class ShowDim(val show: Boolean) : MainUiEvent() | |
data class AdventureAction(val adventure: Adventure) : MainUiEvent() | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\viewmodel\ManageAdventureViewModel.kt | |
```kt | |
package com.divadventure.viewmodel | |
import androidx.lifecycle.viewModelScope | |
import com.divadventure.data.navigation.NavigationEvent | |
import com.divadventure.data.navigation.NavigationEvent.NavigateAdventureOptions | |
import com.divadventure.data.navigation.NavigationEvent.NavigateTo | |
import com.divadventure.data.navigation.Screen | |
import com.divadventure.domain.models.Adventure | |
import com.divadventure.domain.models.AdventureType | |
import com.divadventure.domain.models.Interest | |
import com.divadventure.domain.models.LocationAttributes | |
import com.divadventure.domain.models.Request | |
import com.divadventure.domain.usecase.AdventuresUseCase | |
import com.divadventure.domain.usecase.InterestsUseCase | |
import com.divadventure.domain.usecase.LocationsUseCase | |
import com.divadventure.domain.usecase.RequestsUseCase | |
import com.divadventure.data.Repository.RequestsRepository | |
import com.divadventure.util.NetworkManager | |
import com.divadventure.viewmodel.AdventureUIEvent.NavigateToNextScreen | |
import com.divadventure.viewmodel.AdventureUIEvent.ShowSnackbar | |
import com.google.android.gms.maps.model.LatLng | |
import com.google.android.libraries.places.api.model.AutocompletePrediction | |
import com.google.android.libraries.places.api.model.Place | |
import dagger.hilt.android.lifecycle.HiltViewModel | |
import kotlinx.coroutines.flow.MutableSharedFlow | |
import kotlinx.coroutines.flow.asSharedFlow | |
import kotlinx.coroutines.launch | |
import java.time.Instant | |
import java.time.LocalTime | |
import java.time.ZoneId | |
import java.time.format.DateTimeFormatter | |
import java.util.Locale | |
import javax.inject.Inject | |
@HiltViewModel | |
class ManageAdventureViewModel @Inject constructor( | |
private val locationsUseCase: LocationsUseCase, // Injected use case | |
private val networkManager: NetworkManager, | |
private val interestsUseCase: InterestsUseCase, // Add InterestsUseCase here | |
private val adventuresUseCase: AdventuresUseCase, | |
private val requestsUseCase: RequestsUseCase, | |
private val requestsRepository: RequestsRepository | |
) : BaseViewModel<AdventuresIntent, AdventuresState>(AdventuresState()) { | |
// Function to determine the adventure type for an adventure | |
fun getAdventureType(adventure: Adventure): AdventureType { | |
return adventuresUseCase.checkAdventureType(adventure) | |
} | |
// Function to perform appropriate action based on adventure type | |
fun performActionByType(adventureType: AdventureType, adventureId: String) { | |
viewModelScope.launch { | |
when (adventureType) { | |
AdventureType.Manage -> { | |
//_uiEvent.emit(NavigateToNextScreen( | |
_uiEvent.emit(ShowSnackbar("Join", "Joining the adventure...")) | |
/* | |
NavigateTo( | |
Screen.ManageAdventure, | |
arguments = mapOf("adventureId" to adventureId) | |
) | |
*/ | |
// )) | |
} | |
AdventureType.Join -> { | |
// Handle join action | |
// You might want to call an API endpoint to join the adventure | |
_uiEvent.emit(ShowSnackbar("Join", "Joining the adventure...")) | |
} | |
AdventureType.Going -> { | |
// Handle going action | |
_uiEvent.emit(ShowSnackbar("Going", "You're going to this adventure")) | |
} | |
AdventureType.Pending -> { | |
// Handle pending action | |
_uiEvent.emit(ShowSnackbar("Pending", "Your request is pending approval")) | |
} | |
AdventureType.Leave -> { | |
// Handle leave action | |
// You might want to call an API endpoint to leave the adventure | |
_uiEvent.emit(ShowSnackbar("Leave", "Leaving the adventure...")) | |
} | |
} | |
} | |
} | |
private val _uiEvent = MutableSharedFlow<AdventureUIEvent>(replay = 0) | |
val uiEvent = _uiEvent.asSharedFlow() | |
override suspend fun handleIntent(intent: AdventuresIntent) { | |
when (intent) { | |
is AdventuresIntent.OnDeadlineChange -> { | |
updateState( | |
state.value.copy( | |
deadlineDate = intent.date | |
) | |
) | |
} | |
is AdventuresIntent.OnDescriptionChange -> { | |
updateState( | |
state.value.copy( | |
adventureDescription = intent.description | |
) | |
) | |
} | |
is AdventuresIntent.OnLocationAddressChange -> { | |
updateState( | |
state.value.copy( | |
locationAddress = intent.locationAddress | |
) | |
) | |
} | |
is AdventuresIntent.OnTitleChange -> { | |
updateState( | |
state.value.copy( | |
adventureTitle = intent.title | |
) | |
) | |
} | |
AdventuresIntent.OnUploadBannerImage -> { | |
} | |
is AdventuresIntent.OnLocationLatLngChange -> { | |
updateState( | |
state.value.copy( | |
locationLat = intent.latLng.latitude, | |
locationLng = intent.latLng.longitude, | |
// Assuming newLocation should be cleared if manually clicking on map | |
// and it's not directly tied to a search result Place object. | |
newLocation = null | |
) | |
) | |
} | |
is AdventuresIntent.OnRequestConditionChange -> { | |
state.value.requestCondition = intent.condition | |
} | |
is AdventuresIntent.OnSetEndDate -> { | |
state.value.endDate = intent.endDate | |
state.value.endClock = intent.endClock | |
// Store ISO-formatted date for duration calculations | |
/* | |
try { | |
val localDateTime = LocalDateTime.of(intent.endClock.toLocalDate(), intent.endClock) | |
state.value.endDate = localDateTime.toLocalDate().toString() // Store as ISO format (yyyy-MM-dd) | |
} catch (e: Exception) { | |
// Keep the original format if parsing fails | |
} | |
*/ | |
} | |
is AdventuresIntent.OnSetStartDate -> { | |
state.value.startDate = intent.startDate | |
state.value.startClock = intent.startClock | |
// Store ISO-formatted date for duration calculations | |
/* | |
try { | |
val localDateTime = LocalDateTime.of(intent.startDate.toLocalDate(), intent.startClock) | |
state.value.startDate = localDateTime.toLocalDate().toString() // Store as ISO format (yyyy-MM-dd) | |
} catch (e: Exception) { | |
// Keep the original format if parsing fails | |
} | |
*/ | |
} | |
is AdventuresIntent.OnSetDeadlineDate -> { | |
state.value.deadlineDate = intent.deadlineDate | |
} | |
is AdventuresIntent.LocationFieldChanged -> { | |
updateState( | |
state.value.copy( | |
locationsPredicted = locationsUseCase.predictLocations(intent.query) | |
.toMutableList() | |
) | |
) | |
} | |
is AdventuresIntent.LocationSelected -> { | |
clearPredictedLocations() | |
val location = locationsUseCase.goLocation(intent.selectedLocation.placeId) | |
location?.latLng?.latitude?.let { lat -> | |
location.latLng?.longitude?.let { lng -> | |
updateState( | |
state.value.copy( | |
newLocation = location, | |
locationLat = lat, locationLng = lng | |
) | |
) | |
} | |
} | |
} | |
AdventuresIntent.FetchInterests -> { | |
fetchInterests() | |
} | |
is AdventuresIntent.ApplyInterests -> { | |
updateState( | |
state.value.copy( | |
adventureInterests = intent.interests, | |
) | |
) | |
} | |
AdventuresIntent.GoInterests -> { | |
_uiEvent.emit( | |
NavigateToNextScreen( | |
NavigateTo( | |
Screen.AdventureInterests, | |
onDestinationChangedListener = { navController, destination, arguments -> | |
// Add action or leave it empty based on requirements | |
} | |
) | |
) | |
) | |
} | |
is AdventuresIntent.ApplyPrivacyType -> { | |
state.value.privacyType = intent.privacyType | |
} | |
AdventuresIntent.PreviewAdventure -> { | |
viewModelScope.launch { | |
_uiEvent.emit( | |
NavigateToNextScreen( | |
NavigateTo( | |
Screen.AdventurePreview, | |
onDestinationChangedListener = { navController, destination, arguments -> | |
// Add action or leave it empty based on requirements | |
} | |
) | |
) | |
) | |
} | |
} | |
AdventuresIntent.PublishAdventure -> { | |
publishAdventure() | |
} | |
is AdventuresIntent.GetAdventure -> { | |
extractParamsFromAdventureRequest(intent.adventure) | |
} | |
AdventuresIntent.GoEditAdventure -> { | |
} | |
/* | |
AdventuresIntent.GoOwnerParticipantMenu -> { | |
_uiEvent.emit( | |
NavigateToNextScreen( | |
NavigationEvent.NavigateAdventureOwnerParticipantMenu( | |
adventure = Adventure( | |
id = state.value.adventureId, | |
title = state.value.adventureTitle, | |
description = state.value.adventureDescription, | |
banner = state.value.bannerUrl, | |
privacyType = state.value.privacyType, | |
startsAt = state.value.startDate, | |
endsAt = state.value.endDate, | |
deadline = state.value.deadlineDate, | |
joinRequestNeeded = state.value.requestCondition, | |
ownerId = TODO(), | |
state = TODO(), | |
currentUserAdventurerId = TODO(), | |
adventureRequest = TODO(), | |
adventurersCount = TODO(), | |
adventurers = TODO(), | |
interests = TODO(), | |
location = TODO(), | |
adventureType = TODO(), | |
), | |
onDestinationChangedListener = { navController, destination, arguments -> | |
// Add action or leave it empty based on requirements | |
} | |
) | |
) | |
) | |
} | |
*/ | |
is AdventuresIntent.GoJoinRequests -> { | |
viewModelScope.launch { | |
_uiEvent.emit( | |
NavigateToNextScreen( | |
NavigateAdventureOptions( | |
adventure = intent.adventure, | |
partName = Screen.AdventureJoinRequests, | |
onDestinationChangedListener = { navController, destination, arguments -> | |
// Add action or leave it empty based on requirements | |
}) | |
) | |
) | |
} | |
} | |
is AdventuresIntent.GoInvitationRequests -> { | |
viewModelScope.launch { | |
_uiEvent.emit( | |
NavigateToNextScreen( | |
NavigateAdventureOptions( | |
adventure = intent.adventure, | |
partName = Screen.AdventureInvitationRequests, | |
onDestinationChangedListener = { navController, destination, arguments -> | |
// Add action or leave it empty based on requirements | |
} | |
) | |
) | |
) | |
} | |
} | |
is AdventuresIntent.FetchJoinRequests -> fetchJoinRequests(intent.adventureId) | |
is AdventuresIntent.AcceptJoinRequest -> acceptJoinRequest(intent.adventureId, intent.requestId) | |
is AdventuresIntent.DeclineJoinRequest -> declineJoinRequest(intent.adventureId, intent.requestId) | |
} | |
} | |
private fun fetchJoinRequests(adventureId: String) { | |
viewModelScope.launch { | |
updateState(state.value.copy(isLoadingJoinRequests = true, joinRequestsError = null)) | |
requestsUseCase.fetchAdventureRequests(adventureId) | |
.onSuccess { adventureRequestsResponse -> // adventureRequestsResponse is AdventureRequestsResponse | |
updateState( | |
state.value.copy( | |
isLoadingJoinRequests = false, | |
joinRequestsList = adventureRequestsResponse.requests // Access .requests here | |
) | |
) | |
} | |
.onFailure { exception -> | |
updateState( | |
state.value.copy( | |
isLoadingJoinRequests = false, | |
joinRequestsError = exception.message ?: "Unknown error fetching requests" | |
) | |
) | |
_uiEvent.emit(ShowSnackbar("Error", exception.message ?: "Failed to fetch join requests")) | |
} | |
} | |
} | |
private fun acceptJoinRequest(adventureId: String, requestId: String) { | |
viewModelScope.launch { | |
if (!networkManager.isNetworkConnected()) { | |
_uiEvent.emit(ShowSnackbar("Network Error", "No internet connection available")) | |
return@launch | |
} | |
// Optionally, add a specific loading state for this action | |
// updateState(state.value.copy(isProcessingRequest = true)) | |
requestsUseCase.acceptJoinRequest(adventureId, requestId) | |
.onSuccess { | |
_uiEvent.emit(ShowSnackbar("Success", "Request accepted")) | |
// Refresh the join requests list | |
fetchJoinRequests(adventureId) | |
} | |
.onFailure { exception -> | |
_uiEvent.emit(ShowSnackbar("Error", exception.message ?: "Failed to accept request")) | |
} | |
// updateState(state.value.copy(isProcessingRequest = false)) | |
} | |
} | |
private fun declineJoinRequest(adventureId: String, requestId: String) { | |
viewModelScope.launch { | |
if (!networkManager.isNetworkConnected()) { | |
_uiEvent.emit(ShowSnackbar("Network Error", "No internet connection available")) | |
return@launch | |
} | |
// Optionally, add a specific loading state for this action | |
// updateState(state.value.copy(isProcessingRequest = true)) | |
requestsUseCase.declineJoinRequest(adventureId, requestId) | |
.onSuccess { | |
_uiEvent.emit(ShowSnackbar("Success", "Request declined")) | |
// Refresh the join requests list | |
fetchJoinRequests(adventureId) | |
} | |
.onFailure { exception -> | |
_uiEvent.emit(ShowSnackbar("Error", exception.message ?: "Failed to decline request")) | |
} | |
// updateState(state.value.copy(isProcessingRequest = false)) | |
} | |
} | |
fun extractParamsFromAdventureRequest(adventure: Adventure) { | |
// Parse the date strings from ISO format to formatted display strings | |
val dateTimeFormatter = DateTimeFormatter.ofPattern("MMM dd yyyy - h:mm a", Locale.ENGLISH) | |
val dateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") | |
// Parse the ISO instant strings to ZonedDateTime objects | |
val startsAtInstant = Instant.parse(adventure.startsAt) | |
.atZone(ZoneId.systemDefault()) | |
val endsAtInstant = Instant.parse(adventure.endsAt) | |
.atZone(ZoneId.systemDefault()) | |
/* val deadlineInstant = java.time.Instant.parse(adventure.deadline) | |
.atZone(ZoneId.systemDefault()) */ | |
// Format the dates for display | |
val formattedStartsAt = dateTimeFormatter.format(startsAtInstant) | |
val formattedEndsAt = dateTimeFormatter.format(endsAtInstant) | |
// val formattedDeadline = dateFormatter.format(deadlineInstant) | |
updateState( | |
state.value.copy( | |
startDate = formattedStartsAt, | |
endDate = formattedEndsAt, | |
deadlineDate = adventure.deadline, | |
adventureId = adventure.id, | |
bannerUrl = adventure.banner, | |
adventureTitle = adventure.title, | |
adventureDescription = adventure.description, | |
locationLat = adventure.location.lat, | |
locationLng = adventure.location.lng, | |
privacyType = getPrivacyTypeInt(adventure.privacyType), | |
requestCondition = adventure.joinRequestNeeded, | |
adventureInterests = adventure.interests.toMutableList() | |
) | |
) | |
} | |
private fun getPrivacyTypeInt(string: String): Int { | |
when (string) { | |
"invite_only" -> return 0 | |
"friends_only" -> return 1 | |
"publicly_open" -> return 2 | |
else -> throw IllegalArgumentException("privacy type not defined!!!!!") | |
} | |
} | |
private suspend fun fetchInterests() { | |
state.value.allInterests.clear() | |
if (!networkManager.isNetworkConnected()) { // Handle network checks | |
_uiEvent.emit(ShowSnackbar("Error", "No network connection")) | |
return | |
} | |
updateState(state.value.copy(interestsIsLoading = true)) // Update loading state | |
interestsUseCase.fetchInterests().onSuccess { interests -> | |
updateState(state.value.copy(interestsIsLoading = false)) | |
state.value.allInterests.clear() | |
updateState(state.value) | |
state.value.allInterests.addAll(interests.interests) | |
updateState(state.value.copy(allInterests = interests.interests.toMutableList())) | |
}.onFailure { exception -> | |
updateState(state.value.copy(interestsIsLoading = false)) | |
} | |
} | |
private fun clearPredictedLocations() { | |
updateState( | |
state.value.copy( | |
locationsPredicted = mutableListOf<AutocompletePrediction>() | |
) | |
) | |
} | |
private fun publishAdventure() { | |
viewModelScope.launch { | |
// Update publishing state to show loading | |
updateState(state.value.copy(isPublishing = true)) | |
// Check for network connection | |
if (!networkManager.isNetworkConnected()) { | |
_uiEvent.emit(ShowSnackbar("Network Error", "No internet connection available")) | |
updateState(state.value.copy(isPublishing = false)) | |
return@launch | |
} | |
// Execute the create adventure request | |
adventuresUseCase.createAdventure( | |
title = state.value.adventureTitle, | |
description = state.value.adventureDescription, | |
banner = state.value.bannerUrl, | |
privacyType = state.value.privacyType, | |
startsAt = state.value.startDate, | |
endsAt = state.value.endDate, | |
deadline = state.value.deadlineDate, | |
interests = state.value.adventureInterests.map { it.id }, | |
joinRequestNeeded = state.value.requestCondition, | |
locationAttributes = LocationAttributes( | |
lat = state.value.locationLat.toString(), | |
lng = state.value.locationLng.toString() | |
) | |
).onSuccess { response -> | |
// Show success message | |
_uiEvent.emit(ShowSnackbar("Success", "Adventure published successfully")) | |
// Clear the state after successful creation | |
resetAdventureState() | |
}.onFailure { exception -> | |
// Show error message | |
_uiEvent.emit( | |
ShowSnackbar( | |
"Error", | |
exception.message ?: "Failed to publish adventure" | |
) | |
) | |
} | |
// Reset loading state | |
updateState(state.value.copy(isPublishing = false)) | |
} | |
} | |
private fun resetAdventureState() { | |
// Reset all the adventure creation form fields | |
updateState( | |
state.value.copy( | |
adventureTitle = "", | |
adventureDescription = "", | |
startDate = "", | |
endDate = "", | |
deadlineDate = "", | |
locationAddress = "", | |
locationLat = 0.0, | |
locationLng = 0.0, | |
bannerUrl = "", | |
requestCondition = false, | |
adventureInterests = mutableListOf(), | |
privacyType = 2 | |
) | |
) | |
} | |
} | |
sealed class AdventuresIntent { | |
data class OnTitleChange(val title: String) : AdventuresIntent() | |
data class OnDescriptionChange(val description: String) : AdventuresIntent() | |
data class OnDeadlineChange(val date: String) : AdventuresIntent() | |
data class OnLocationAddressChange(val locationAddress: String) : AdventuresIntent() | |
data class OnLocationLatLngChange(val latLng: LatLng) : AdventuresIntent() | |
object OnUploadBannerImage : AdventuresIntent() | |
data class OnRequestConditionChange(val condition: Boolean) : AdventuresIntent() | |
data class OnSetStartDate(val startDate: String, val startClock: LocalTime) : AdventuresIntent() | |
data class OnSetEndDate(val endDate: String, val endClock: LocalTime) : AdventuresIntent() | |
data class OnSetDeadlineDate(val deadlineDate: String) : AdventuresIntent() | |
data class LocationFieldChanged(val query: String) : AdventuresIntent() | |
data class LocationSelected(val selectedLocation: AutocompletePrediction) : AdventuresIntent() | |
object GoEditAdventure : AdventuresIntent() | |
data class GoJoinRequests(val adventure: Adventure) : AdventuresIntent() | |
data class GoInvitationRequests(val adventure: Adventure) : AdventuresIntent() | |
object GoInterests : AdventuresIntent() | |
object FetchInterests : AdventuresIntent() | |
data class ApplyInterests(val interests: MutableList<Interest>) : AdventuresIntent() | |
data class ApplyPrivacyType(val privacyType: Int) : AdventuresIntent() | |
object PreviewAdventure : AdventuresIntent() | |
object PublishAdventure : AdventuresIntent() | |
data class GetAdventure(val adventure: Adventure) : AdventuresIntent() | |
data class FetchJoinRequests(val adventureId: String) : AdventuresIntent() | |
data class AcceptJoinRequest(val adventureId: String, val requestId: String) : AdventuresIntent() | |
data class DeclineJoinRequest(val adventureId: String, val requestId: String) : AdventuresIntent() | |
} | |
data class AdventuresState( | |
var adventureId: String = "", | |
var adventureTitle: String = "", | |
var adventureDescription: String = "", | |
var startClock: LocalTime = LocalTime.now(), | |
var startDate: String = "", | |
var allInterests: MutableList<Interest> = mutableListOf<Interest>(), | |
var adventureInterests: MutableList<Interest> = mutableListOf<Interest>(), | |
var endClock: LocalTime = LocalTime.now(), | |
var endDate: String = "", | |
var deadlineDate: String = "", | |
var locationAddress: String = "", | |
var locationLat: Double = 0.0, | |
var locationLng: Double = 0.0, | |
var interests: MutableList<String> = mutableListOf<String>(), | |
var privacyType: Int = 2, | |
var bannerUrl: String = "", | |
var requestCondition: Boolean = false, | |
var locationsPredicted: MutableList<AutocompletePrediction> = mutableListOf(), | |
var newLocation: Place? = null, | |
var locationIsLoading: Boolean = false, | |
var interestsIsLoading: Boolean = false, | |
var isPublishing: Boolean = false, | |
val joinRequestsList: List<Request> = emptyList(), | |
val isLoadingJoinRequests: Boolean = false, | |
val joinRequestsError: String? = null | |
) | |
sealed class AdventureUIEvent() { | |
data class ShowSnackbar( | |
val title: String, val message: String | |
) : AdventureUIEvent() | |
object AnimateItem : AdventureUIEvent() | |
data class NavigateToNextScreen(val navigationEvent: NavigationEvent) : AdventureUIEvent() | |
object ShowStartDateDialog : AdventureUIEvent() | |
object ShowEndDateDialog : AdventureUIEvent() | |
} | |
// Function to perform appropriate action based on adventure type | |
/* | |
fun performActionByType(adventureType: AdventureType, adventureId: String) { | |
viewModelScope.launch { | |
when(adventureType) { | |
AdventureType.Manage -> { | |
_uiEvent.emit(NavigateToNextScreen( | |
NavigateTo( | |
Screen.ManageAdventure, | |
arguments = mapOf("adventureId" to adventureId) | |
) | |
)) | |
} | |
AdventureType.Join -> { | |
// Handle join action by calling UseCase methods | |
// adventuresUseCase.joinAdventure(adventureId) | |
_uiEvent.emit(ShowSnackbar("Join", "Joining the adventure...")) | |
} | |
AdventureType.Going -> { | |
// Handle going action | |
_uiEvent.emit(ShowSnackbar("Going", "You're going to this adventure")) | |
} | |
AdventureType.Pending -> { | |
// Handle pending action | |
_uiEvent.emit(ShowSnackbar("Pending", "Your request is pending approval")) | |
} | |
AdventureType.Leave -> { | |
// Handle leave action by calling UseCase methods | |
// adventuresUseCase.leaveAdventure(adventureId) | |
_uiEvent.emit(ShowSnackbar("Leave", "Leaving the adventure...")) | |
} | |
} | |
} | |
}*/ | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\viewmodel\NotificationsViewModel.kt | |
```kt | |
package com.divadventure.viewmodel | |
import androidx.lifecycle.LiveData | |
import androidx.lifecycle.MutableLiveData | |
import com.divadventure.data.SharedService | |
import com.divadventure.data.navigation.NavigationEvent | |
import com.divadventure.di.SharedPrefs | |
import com.divadventure.util.NetworkManager | |
import dagger.hilt.android.lifecycle.HiltViewModel | |
import kotlinx.coroutines.flow.MutableSharedFlow | |
import kotlinx.coroutines.flow.asSharedFlow | |
import javax.inject.Inject | |
@HiltViewModel | |
class NotificationsViewModel @Inject constructor( | |
private val sharedService: SharedService, | |
private val sharedPrefs: SharedPrefs, | |
private val networkManager: NetworkManager | |
) : BaseViewModel<NotificationsIntent, NotificationsState>(NotificationsState()) { | |
private val _uiEvent = MutableSharedFlow<NotificationsUiEvent>(replay = 0) | |
val uiEvent = _uiEvent.asSharedFlow() | |
private val _notifications = MutableLiveData<List<String>>() | |
val notifications: LiveData<List<String>> get() = _notifications | |
init { | |
loadNotifications() | |
} | |
private fun loadNotifications() { | |
// Example: Load notifications from a data source | |
_notifications.value = listOf("Notification 1", "Notification 2", "Notification 3") | |
} | |
override suspend fun handleIntent(intent: NotificationsIntent) { | |
when (intent) { | |
NotificationsIntent.LoadNotifications -> { | |
} | |
NotificationsIntent.ShowBottomSheet -> { | |
_uiEvent.emit(NotificationsUiEvent.ShowBottomSheet) | |
} | |
} | |
} | |
} | |
sealed class NotificationsIntent { | |
object LoadNotifications : NotificationsIntent() | |
object ShowBottomSheet : NotificationsIntent() | |
} | |
data class NotificationsState(val state: String = "") | |
sealed class NotificationsUiEvent { | |
data class ShowSnackbar( | |
val title: String, val message: String | |
) : NotificationsUiEvent() | |
object ShowBottomSheet : NotificationsUiEvent() | |
object AnimateItem : NotificationsUiEvent() | |
data class NavigateToNextScreen(val navigationEvent: NavigationEvent) : NotificationsUiEvent() | |
object ShowDialog : NotificationsUiEvent() | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\viewmodel\ProfileViewModel.kt | |
```kt | |
package com.divadventure.viewmodel | |
import android.os.Bundle | |
import androidx.lifecycle.viewModelScope | |
import androidx.navigation.NavController | |
import androidx.navigation.NavController.OnDestinationChangedListener | |
import androidx.navigation.NavDestination | |
import com.divadventure.data.navigation.NavigationEvent | |
import com.divadventure.data.navigation.NavigationEvent.NavigateAdventure | |
import com.divadventure.di.SharedPrefs | |
import com.divadventure.di.UserPrefs.KEY_AVATAR | |
import com.divadventure.di.UserPrefs.KEY_BIO | |
import com.divadventure.di.UserPrefs.KEY_BIRTH_DATE | |
import com.divadventure.di.UserPrefs.KEY_FIRST_NAME | |
import com.divadventure.di.UserPrefs.KEY_ID | |
import com.divadventure.di.UserPrefs.KEY_LOCATION | |
import com.divadventure.di.UserPrefs.KEY_USERNAME | |
import com.divadventure.domain.models.Adventure | |
import com.divadventure.domain.models.AdventureType | |
import com.divadventure.domain.models.Friend | |
import com.divadventure.domain.models.UsersData | |
import com.divadventure.domain.usecase.AdventuresUseCase | |
import com.divadventure.domain.usecase.UsersUseCase | |
import com.divadventure.util.NetworkManager | |
import com.divadventure.viewmodel.ProfileUIEvent.ShowBottomSheet | |
import dagger.hilt.android.lifecycle.HiltViewModel | |
import kotlinx.coroutines.flow.MutableSharedFlow | |
import kotlinx.coroutines.flow.asSharedFlow | |
import kotlinx.coroutines.launch | |
import timber.log.Timber | |
import javax.inject.Inject | |
@HiltViewModel | |
class ProfileViewModel @Inject constructor( | |
private val networkManager: NetworkManager, | |
private val usersUseCase: UsersUseCase, private val sharedPrefs: SharedPrefs, | |
private val adventureUseCase: AdventuresUseCase | |
) : BaseViewModel<ProfileIntent, ProfileState>(ProfileState()) { | |
private val _uiEvent = MutableSharedFlow<ProfileUIEvent>(replay = 0) | |
val uiEvent = _uiEvent.asSharedFlow() | |
override suspend fun handleIntent(intent: ProfileIntent) { | |
when (intent) { | |
ProfileIntent.ClearAllShared -> { | |
} | |
is ProfileIntent.LoadElseProfileData -> { | |
loadUserData(intent.profileId) | |
} | |
ProfileIntent.Logout -> { | |
} | |
ProfileIntent.Refresh -> { | |
} | |
ProfileIntent.GetFriends -> { | |
} | |
ProfileIntent.LoadMyFriends -> { | |
fetchMyFriends() | |
} | |
is ProfileIntent.LoadElseFriends -> { | |
state.value.isMe = false | |
fetchElseFriends(intent.profileId) | |
} | |
ProfileIntent.LoadMyAdventures -> { | |
fetchMyAdventures() | |
} | |
ProfileIntent.LoadElseAdventures -> { | |
fetchElseAdventures() | |
} | |
ProfileIntent.OpenProfileBottomSheet -> { | |
_uiEvent.emit(ShowBottomSheet(true)) | |
} | |
is ProfileIntent.CheckId -> { | |
updateState( | |
state.value.copy( | |
isMe = intent.id == sharedPrefs.getString(KEY_ID) | |
) | |
) | |
} | |
ProfileIntent.LoadMyUserData -> { | |
updateState( | |
state.value.copy( | |
birthdate = sharedPrefs.getString(KEY_BIRTH_DATE) ?: "", | |
bio = sharedPrefs.getString(KEY_BIO) ?: "", | |
firstName = sharedPrefs.getString(KEY_FIRST_NAME) ?: "", | |
lastName = sharedPrefs.getString(KEY_FIRST_NAME) ?: "", | |
userId = sharedPrefs.getString(KEY_ID) ?: "", | |
avatar = sharedPrefs.getString(KEY_AVATAR) ?: "", | |
username = sharedPrefs.getString(KEY_USERNAME) ?: "", | |
location = sharedPrefs.getString(KEY_LOCATION) ?: "", | |
) | |
) | |
} | |
ProfileIntent.ApplyAdevntureType -> { | |
state.value.adventuresList.forEach { | |
it.adventureType = adventureUseCase.checkAdventureType(it) | |
} | |
} | |
is ProfileIntent.HandleAdventureClick -> { | |
val adventure = intent.adventure | |
when (adventure.adventureType) { | |
AdventureType.Join -> { | |
if (adventure.joinRequestNeeded) { | |
updateAdventureState(adventure.id, AdventureType.Pending) | |
} else { | |
// As per issue: "if join request need is false , it should change to Leave" | |
updateAdventureState(adventure.id, AdventureType.Leave) | |
// Potentially, this could also mean remove it immediately if it becomes "Leave" | |
// removeAdventureFromLists(adventure.id) // Decided to keep it visible as "Leave" first | |
} | |
} | |
AdventureType.Going -> { | |
updateState( | |
state.value.copy( | |
showGoingBottomSheet = true, | |
selectedAdventureForBottomSheet = adventure | |
) | |
) | |
} | |
AdventureType.Pending -> { | |
updateAdventureState(adventure.id, AdventureType.Join) | |
} | |
AdventureType.Leave -> { | |
// When a "Leave" button is clicked (originally it was "Leave") | |
removeAdventureFromLists(adventure.id) | |
} | |
AdventureType.Manage -> { | |
// This case should ideally not be sent to HomeViewModel via HandleAdventureClick | |
// as MainViewModel handles navigation for Manage. Log if it occurs. | |
Timber.w("HandleAdventureClick received for Manage type: ${adventure.id}") | |
manageAdventure(adventure) | |
} | |
null -> { | |
// Handle null case if necessary, perhaps log an error or default behavior | |
Timber.e("AdventureType is null for adventure: ${adventure.id}") | |
} | |
} | |
} | |
} | |
} | |
private fun manageAdventure(adventure: Adventure) { | |
// Proceed with existing navigation logic for Manage | |
viewModelScope.launch { | |
try { | |
val safeAdventure = adventure.copy( | |
adventureRequest = adventure.adventureRequest ?: emptyList(), | |
adventurers = adventure.adventurers.ifEmpty { emptyList() }) | |
_uiEvent.emit( | |
ProfileUIEvent.NavigateToNextScreen( | |
NavigateAdventure( | |
adventure = safeAdventure, | |
onDestinationChangedListener = object : OnDestinationChangedListener { | |
override fun onDestinationChanged( | |
controller: NavController, | |
destination: NavDestination, | |
arguments: Bundle? | |
) { | |
// Implement the required behavior here if needed | |
} | |
}, | |
removeListenerAfter = true | |
) | |
) | |
) | |
} catch (e: Exception) { | |
e.printStackTrace() | |
} | |
} | |
} | |
private fun removeAdventureFromLists(adventureId: String) { | |
val currentMainList = state.value.adventuresList.toMutableList() | |
currentMainList.removeAll { it.id == adventureId } | |
updateState( | |
state.value.copy( | |
adventuresList = currentMainList, | |
) | |
) | |
} | |
private fun updateAdventureState(adventureId: String, newType: AdventureType) { | |
val currentMainList = state.value.adventuresList.toMutableList() | |
val mainIndex = currentMainList.indexOfFirst { it.id == adventureId } | |
if (mainIndex != -1) { | |
currentMainList[mainIndex] = currentMainList[mainIndex].copy(adventureType = newType) | |
} | |
updateState( | |
state.value.copy( | |
adventuresList = currentMainList | |
) | |
) | |
} | |
private suspend fun fetchElseAdventures() { | |
if (!networkManager.isNetworkConnected()) { | |
_uiEvent.emit(ProfileUIEvent.ShowSnackbar("Error", "No network connection")) | |
return | |
} | |
// Update loading state to show adventures are being fetched | |
updateState(state.value.copy(isLoading = state.value.isLoading.copy(adventuresLoading = true))) | |
try { | |
// Call the use case to fetch the else adventures | |
val result = adventureUseCase.getElseAdventures( | |
profileId = state.value.userId | |
) | |
result.onSuccess { adventuresResponse -> | |
state.value.adventuresList.clear() | |
updateState(state.value) | |
// Update the state with the fetched adventures | |
state.value.adventuresList.addAll(adventuresResponse.adventures) | |
updateState( | |
state.value.copy( | |
adventuresList = adventuresResponse.adventures.toMutableList(), | |
isLoading = state.value.isLoading.copy(adventuresLoading = false) | |
) | |
) | |
}.onFailure { exception -> | |
// Emit an error snackbar with a descriptive message | |
_uiEvent.emit( | |
ProfileUIEvent.ShowSnackbar( | |
"Error", exception.message ?: "Failed to fetch adventures" | |
) | |
) | |
// Make sure loading state is reset | |
updateState( | |
state.value.copy( | |
isLoading = state.value.isLoading.copy( | |
adventuresLoading = false | |
) | |
) | |
) | |
} | |
} catch (e: Exception) { | |
// Emit a snackbar for unexpected errors | |
_uiEvent.emit( | |
ProfileUIEvent.ShowSnackbar( | |
"Error", "An unexpected error occurred: ${e.message}" | |
) | |
) | |
// Reset loading state | |
updateState(state.value.copy(isLoading = state.value.isLoading.copy(adventuresLoading = false))) | |
} | |
} | |
private suspend fun loadUserData(profileId: String) { | |
if (!networkManager.isNetworkConnected()) { // Handle network checks | |
Timber.e("No network connection while attempting to load user data for profileId: $profileId") | |
_uiEvent.emit(ProfileUIEvent.ShowSnackbar("Error", "No network connection")) | |
return | |
} | |
Timber.d("Fetching user data for profileId: $profileId") | |
val result = usersUseCase.getUserData(profileId) | |
result.onSuccess { profile -> | |
Timber.d("Successfully fetched user data for profileId: $profileId") | |
updateState( | |
state.value.copy( | |
userId = profile.data.id, | |
avatar = profile.data.avatar ?: "", | |
username = profile.data.username, | |
firstName = profile.data.firstName, | |
lastName = profile.data.lastName, | |
birthdate = profile.data.birthdate ?: "", | |
bio = profile.data.bio ?: "", | |
statusWithUser = profile.data.statusWithUser ?: "", | |
elseUserData = profile | |
) | |
) | |
}.onFailure { t -> | |
Timber.e(t, "Failed to load user data for profileId: $profileId") | |
_uiEvent.emit(ProfileUIEvent.ShowSnackbar("Error", "Failed to load user data")) | |
} | |
} | |
private suspend fun fetchMyAdventures() { | |
// Check for network connection | |
if (!networkManager.isNetworkConnected()) { | |
_uiEvent.emit(ProfileUIEvent.ShowSnackbar("Error", "No network connection")) | |
return | |
} | |
// Update loading state to show adventures are being fetched | |
updateState(state.value.copy(isLoading = state.value.isLoading.copy(adventuresLoading = true))) | |
try { | |
// Call the use case to fetch the user's adventures | |
val result = adventureUseCase.getMyAdventures() | |
// Handle the result | |
result.onSuccess { adventuresResponse -> | |
state.value.adventuresList.clear() | |
updateState(state.value) | |
// Update the state with the fetched adventures | |
state.value.adventuresList.addAll(adventuresResponse.adventures) | |
updateState( | |
state.value.copy( | |
adventuresList = adventuresResponse.adventures.toMutableList(), | |
isLoading = state.value.isLoading.copy(adventuresLoading = false) | |
) | |
) | |
}.onFailure { exception -> | |
// Emit an error snackbar with a descriptive message | |
_uiEvent.emit( | |
ProfileUIEvent.ShowSnackbar( | |
"Error", exception.message ?: "Failed to fetch adventures" | |
) | |
) | |
// Make sure loading state is reset | |
updateState( | |
state.value.copy( | |
isLoading = state.value.isLoading.copy( | |
adventuresLoading = false | |
) | |
) | |
) | |
} | |
} catch (e: Exception) { | |
// Emit a snackbar for unexpected errors | |
_uiEvent.emit( | |
ProfileUIEvent.ShowSnackbar( | |
"Error", "An unexpected error occurred: ${e.message}" | |
) | |
) | |
// Reset loading state | |
updateState(state.value.copy(isLoading = state.value.isLoading.copy(adventuresLoading = false))) | |
} | |
} | |
private suspend fun fetchElseFriends(profileId: String) { | |
Timber.d("Starting fetchElseFriends for profileId: $profileId") | |
if (!networkManager.isNetworkConnected()) { // Handle network checks | |
Timber.e("No network connection while attempting to fetch friends for profileId: $profileId") | |
_uiEvent.emit(ProfileUIEvent.ShowSnackbar("Error", "No network connection")) | |
return | |
} | |
Timber.d("Network connected. Updating loading state for fetchElseFriends.") | |
updateState(state.value.copy(isLoading = state.value.isLoading.copy(friendsIsLoading = true))) // Update loading state | |
usersUseCase.getElseFriends(profileId).onSuccess { | |
Timber.d("Successfully fetched friends for profileId: $profileId") | |
state.value.friends.clear() | |
updateState(state.value) | |
state.value.friends.addAll(it.friends) | |
updateState(state.value) | |
_uiEvent.emit(ProfileUIEvent.ShowSnackbar("Success", "Search completed")) | |
Timber.d("Friend list updated successfully for profileId: $profileId") | |
}.onFailure { exception -> | |
Timber.e(exception, "Failed to fetch friends for profileId: $profileId") | |
updateState(state.value.copy(isLoading = state.value.isLoading.copy(friendsIsLoading = false))) | |
_uiEvent.emit( | |
ProfileUIEvent.ShowSnackbar( | |
"Error", exception.localizedMessage ?: "Unknown error" | |
) | |
) | |
} | |
Timber.d("fetchElseFriends method completed for profileId: $profileId") | |
} | |
private suspend fun fetchMyFriends() { | |
if (!networkManager.isNetworkConnected()) { // Handle network checks | |
_uiEvent.emit(ProfileUIEvent.ShowSnackbar("Error", "No network connection")) | |
return | |
} | |
updateState(state.value.copy(isLoading = state.value.isLoading.copy(friendsIsLoading = true))) // Update loading state | |
usersUseCase.getFriends().onSuccess { friends -> | |
state.value.friends.clear() | |
updateState(state.value) | |
state.value.friends.addAll(friends.friends) | |
updateState(state.value) | |
// Handle success, e.g., update state or navigate | |
_uiEvent.emit(ProfileUIEvent.ShowSnackbar("Success", "Search completed")) | |
}.onFailure { exception -> | |
updateState(state.value.copy(isLoading = state.value.isLoading.copy(friendsIsLoading = false))) | |
_uiEvent.emit( | |
ProfileUIEvent.ShowSnackbar( | |
"Error", exception.localizedMessage ?: "Unknown error" | |
) | |
) | |
} | |
} | |
} | |
sealed class ProfileIntent { | |
data class LoadElseProfileData(val profileId: String) : ProfileIntent() | |
object OpenProfileBottomSheet : ProfileIntent() | |
data class CheckId(val id: String) : ProfileIntent() | |
object GetFriends : ProfileIntent() | |
object Refresh : ProfileIntent() | |
object Logout : ProfileIntent() | |
object LoadMyAdventures : ProfileIntent() | |
object LoadElseAdventures : ProfileIntent() | |
object ClearAllShared : ProfileIntent() | |
object LoadMyFriends : ProfileIntent() | |
object LoadMyUserData : ProfileIntent() | |
data class LoadElseFriends(val profileId: String) : ProfileIntent() | |
object ApplyAdevntureType : ProfileIntent() | |
data class HandleAdventureClick(val adventure: Adventure) : ProfileIntent() | |
} | |
data class ProfileState( | |
var userId: String = "", | |
var avatar: String? = "", | |
var username: String = "", | |
var firstName: String = "", | |
var birthdate: String = "", | |
var lastName: String = "", | |
var statusWithUser: String = "", | |
var location: String = "", | |
var bio: String = "", | |
var isMe: Boolean = true, | |
var friends: MutableList<Friend> = mutableListOf(), | |
var currentFriendsPage: Int = 0, | |
var adventuresList: MutableList<Adventure> = mutableListOf(), | |
var isLoading: LoadingProfileState = LoadingProfileState(), | |
var buttonTitle: String = "Edit Profile", | |
var elseUserData: UsersData? = null, | |
var selectedAdventureForBottomSheet: Adventure? = null, | |
var showGoingBottomSheet: Boolean = false | |
) | |
data class LoadingProfileState( | |
var profileIsLoading: Boolean = false, | |
var friendsIsLoading: Boolean = false, | |
var isLoadingMore: Boolean = false, | |
var adventuresLoading: Boolean = false | |
) | |
sealed class ProfileUIEvent() { | |
data class ShowSnackbar( | |
val title: String, val message: String | |
) : ProfileUIEvent() | |
data class ShowBottomSheet(val show: Boolean) : ProfileUIEvent() | |
object AnimateItem : ProfileUIEvent() | |
data class NavigateToNextScreen(val navigationEvent: NavigationEvent) : ProfileUIEvent() | |
object ShowDialog : ProfileUIEvent() | |
data class ShowDim(val show: Boolean) : ProfileUIEvent() | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\readme.md | |
```md | |
# **Div Adventures** | |
 | |
## **Project Description** | |
**Div Adventures** is an innovative social media application designed with a specific focus on sharing users' adventurous experiences and trips. It allows users to connect with others who share similar interests and build friendships around their love for exploration and adventure. The app incorporates various advanced Android development features and adheres to modern software architecture principles, making it a robust and high-performing platform for adventure enthusiasts. | |
--- | |
### **Core Features** | |
1. **Adventure Sharing**: | |
- Users can upload **photos** and write **textual descriptions** to share their adventures. | |
- The shared content is visible to friends and strangers, depending on the user’s privacy settings. | |
- Start and end times for adventures are now displayed in a clear 'MMM dd yyyy - h:mm a' format (e.g., 'Jun 12 2023 - 9:00 PM'), improving readability. | |
- Selected interests for an adventure are presented neatly as a comma-separated list (e.g., 'Hiking, Camping, Photography'). | |
- Each adventure has two types of roles: **owner** and **participants**. There is always only one owner who has full control over the adventure, including the ability to update it. | |
- Adventures can have three types of privacy settings: **public** (visible to everyone), **friends only** (visible only to friends), and **by invitation** (visible only to invited users). | |
- The adventure owner can designate certain participants as **moderators** who have the ability to invite other users to the adventure. | |
- **Adventure Card Interactions**: The adventure cards feature dynamic action buttons that change based on the user's relationship and status with the adventure: | |
- **Manage**: If the user is the owner of the adventure, a "Manage" button is displayed, allowing them to edit or manage their adventure details. | |
- **Join**: If the adventure is available to join: | |
- If a join request is needed, clicking "Join" changes the button to "Pending", indicating the request has been sent. | |
- If no join request is needed, clicking "Join" changes the button to "Leave", signifying the user has joined and can now choose to leave. | |
- **Pending**: If the user has a pending request to join an adventure, a "Pending" button is shown. Clicking this button again will revert it to "Join", effectively canceling the join request. | |
- **Going**: If the user is marked as "Going" to an adventure, a "Going" button is displayed. Clicking this button opens a bottom sheet with three options: | |
- **"Yes"**: Confirms continued participation, but changes the button to "Leave", allowing the user to leave later. | |
- **"No"**: The user chooses not to go, and the adventure card is removed from their list. | |
- **"Maybe"**: The bottom sheet is dismissed with no change in status. The user remains "Going". | |
- **Leave**: If the user has joined an adventure (either directly or after being "Going"), a "Leave" button is shown. Clicking this will remove the user from the adventure, and the card will be removed from their list. | |
2. **Search and Discover Adventures**: | |
- Adventures can be searched based on: | |
- **Interests** (e.g., hiking, diving, camping). | |
- **Time** (e.g., specific dates or duration of the trip). | |
- **Geolocations** (e.g., destinations or nearby adventures). | |
- Users can find content tailored to their preferences or explore diverse categories of adventure-based activities. | |
3. **Adventure Categories**: | |
- The app supports a **diverse range of adventure-related categories**, allowing users to classify and explore adventures more meaningfully. | |
4. **Friendship and Privacy**: | |
- Users can **become friends** by sending and accepting friend requests. | |
- Enforced **privacy settings** offer the choice between **private accounts** (where only approved friends can view content) or **public accounts** (open for everyone to explore). | |
5. **Navigation and UI Framework**: | |
- A **three-tab navigation-based UI** makes the application intuitive and easy to use. | |
- The **main screen layout** provides seamless access to important features like the user’s feed, search functionalities, and profile management. | |
6. **In-App Animations and Visuals**: | |
- The app integrates **Lottie animations** to provide an engaging visual experience. | |
- Smooth transition effects and visually rich components enhance the user experience. | |
--- | |
### **Backend and Storage** | |
- Currently, the app relies heavily on **SharedPreferences** for minimal local storage. | |
- **Room Database** may be integrated later to provide structured offline data storage for caching or improved local performance. | |
- API integration is implemented using **Retrofit** for network communication, ensuring efficient and responsive data handling. | |
--- | |
### **Design and UI** | |
- The design follows a **Figma-based wireframe**, translating ideas into sleek, modern UI components. | |
- Built entirely using **Jetpack Compose**, ensuring reactive and declarative UI design principles. | |
- **Accompanist** is used to handle additional UI utilities like permissions, making the implementation of user flows smoother. | |
- Enhanced time selection with an AM/PM toggle for more precise time input within date/time dialogs. | |
--- | |
### **State Management and Architecture** | |
- **MVI Architecture**: | |
- The app is structured around the **Model-View-Intent (MVI)** architecture, ensuring clean separation of concerns and predictable state flows. | |
- **Jetpack ViewModels**, combined with **Flows** and **Composable functions**, ensure that app state and UI updates are handled efficiently. | |
- **Dependency Injection**: | |
- The project leverages **HILT** for dependency injection, making the codebase modular and easy to maintain. | |
### **ViewModels Implementation** | |
The application implements a robust ViewModel architecture to manage state and user interactions: | |
1. **BaseViewModel**: | |
- Abstract foundation class that implements the core MVI pattern | |
- Manages state through Kotlin Flow (StateFlow and SharedFlow) | |
- Provides a unified approach to handling intents via a Channel | |
- Encapsulates navigation events for consistent screen transitions | |
2. **Specialized ViewModels**: | |
- **AuthViewModel**: Manages authentication flows including login, signup, verification, and password management | |
- **MainViewModel**: Controls the main application flow and navigation between key screens | |
- **ManageAdventureViewModel**: Handles adventure creation, editing, and publishing workflow | |
- **NotificationsViewModel**: Manages user notifications and related UI states | |
3. **State Management**: | |
- Each ViewModel maintains its own state class (e.g., AdventuresState, AuthState) | |
- States are immutable and updated through controlled state transitions | |
- UI components observe these states via StateFlow for reactive updates | |
4. **Intent Processing**: | |
- User actions are translated into typed intents (sealed classes) | |
- Intents are processed asynchronously through coroutine channels | |
- Each intent triggers specific business logic and potential state updates | |
5. **UI Events**: | |
- One-time events (navigation, snackbars, dialogs) are handled through SharedFlow | |
- Prevents event duplication during configuration changes | |
- Maintains clear separation between persistent state and transient events | |
### **ViewModel Implementation** | |
- **BaseViewModel**: | |
- The app uses a custom `BaseViewModel` abstract class that serves as the foundation for all ViewModels in the application. | |
- Implements a standardized approach for handling intents through a Channel, state management via StateFlow, and navigation events with SharedFlow. | |
- Provides a clean, consistent API for all derived ViewModels to process user actions and update UI state. | |
- **Key ViewModels**: | |
- **AuthViewModel**: Manages all authentication flows including signup, login, verification, password reset, and onboarding. Implements specialized handlers for each authentication stage. | |
- **MainViewModel**: Controls the main application navigation and global actions, serving as a central coordination point between different screens and features. | |
- **ManageAdventureViewModel**: Handles the creation, editing, and preview of adventures, managing complex form state, location selection, and interest tagging. | |
- **NotificationsViewModel**: Manages the display and interaction with user notifications, supporting read status tracking and action responses. | |
- **State Management Approach**: | |
- Each ViewModel maintains its own state class that encapsulates all UI-relevant data. | |
- The states are immutable, with updates handled through copy operations to ensure state integrity. | |
- ViewModels expose a single StateFlow for Composables to observe, simplifying the reactive data flow. | |
- **Intent Processing**: | |
- User actions are translated into strongly-typed Intent sealed classes. | |
- Intents are processed asynchronously through a Channel to avoid state conflicts. | |
- Each ViewModel implements specialized intent handlers that encapsulate the business logic for specific user actions. | |
--- | |
### **Integrated Libraries and APIs** | |
1. **Image Loading & Caching**: | |
- **Coil**: For efficient image loading and caching, ensuring smooth performance during image-heavy interactions. | |
2. **Calendar and Date Handling**: | |
- **Kizitonwose** library is used for implementing calendar functionalities, enabling users to view adventures based on specific dates. | |
3. **Maps and Location-Based Features**: | |
- **Google Maps API** is integrated for: | |
- Geolocation tagging. | |
- Viewing adventure locations on maps. | |
- Discovering adventures nearby. | |
4. **Authentication**: | |
- **Ohteepee** (or "OkHttp") is incorporated for secure and efficient user authentication and API communication. | |
5. **Permissions**: | |
- **Accompanist** simplifies the handling of runtime permissions, streamlining flows like accessing the camera or retrieving location data. | |
--- | |
### **Current Progress and Roadmap** | |
1. **Completed Features**: | |
- User authentication and secure access. | |
- Adventure sharing with images, text, geolocation tagging, and filtering by interests. | |
- Core navigation with a three-tab UI design. | |
- Integration of key third-party libraries like Coil, Retrofit, and Kizitonwose. | |
2. **Upcoming Enhancements**: | |
- Implementation of **messaging** and direct communication between friends. | |
- Addition of a **Room Database** for offline capabilities and better caching. | |
- Improved recommendations for exploring adventures based on past preferences, interests, and geolocation. | |
- Enhanced sorting and filtering mechanisms for a personalized feed experience. | |
--- | |
### **Future Potential** | |
- **Business Goals**: | |
- Incorporating **monetization features** like in-app purchases for premium categories or advertisements for adventure-related services. | |
- Expanding into a global audience by integrating multi-language support and region-specific recommendations. | |
- **Analytics and Insights**: | |
- Using tools like **Firebase Analytics** to track user behavior and refine the app's focus based on data-driven decisions. | |
- Building personalized recommendations based on user interactions, search history, and location preferences. | |
--- | |
### **System Requirements** | |
- **Android Studio**: Arctic Fox (2023.2.1) or higher | |
- **Gradle Version**: 8.4+ | |
- **JDK Version**: 11 or higher | |
- **Target Android SDK**: 35 | |
- **Minimum Android SDK**: 24 (Android 7.0 Nougat) | |
- **Kotlin Version**: 1.9.22+ | |
- **Google Maps API Key**: Required for map functionality | |
- **Device Requirements**: Android device or emulator running Android 7.0 (API 24) or higher | |
--- | |
### **Technical Overview** | |
**Key Technologies**: | |
- **Language**: Kotlin (with Jetpack Compose for UI) | |
- **Architecture**: MVI | |
- **State Management**: ViewModel with `StateFlow` and `SharedFlow` | |
- **Dependency Injection**: HILT | |
- **Networking**: Retrofit, OkHttp | |
- **Storage**: SharedPreferences, potentially Room | |
**External Libraries Used**: | |
- **Coil**: Image loading and caching. | |
- **Kizitonwose**: Calendar handling. | |
- **Google Maps API**: Geolocation and route assistance. | |
- **Lottie**: For visually appealing animations. | |
- **Accompanist**: Permissions handling. | |
--- | |
### **Development Setup** | |
1. **Google Maps API Setup**: | |
- Create a project in the [Google Cloud Console](https://console.cloud.google.com/) | |
- Enable the Maps SDK for Android and Places API | |
- Create an API key with appropriate restrictions | |
- Add your API key to the designated location in strings.xml | |
2. **Build and Run**: | |
- Open the project in Android Studio | |
- Sync Gradle files | |
- Build and run the app on an emulator or physical device | |
3. **Testing**: | |
- Unit tests can be run via Gradle or Android Studio | |
- UI tests require an emulator or connected device | |
4. **Security Notes**: | |
- Keep API keys confidential and use proper restrictions | |
- Use version control .gitignore to prevent sensitive data from being committed | |
- Follow secure coding practices when handling user data | |
--- | |
### **Project Architecture** | |
The application follows a modular architecture based on MVI pattern: | |
- **Presentation Layer**: | |
- UI components built with Jetpack Compose | |
- ViewModels handling UI state and user intents | |
- Navigation using the Navigation Compose library | |
- **Domain Layer**: | |
- Business logic and use cases | |
- Model definitions representing core entities | |
- Repository interfaces defining data operations | |
- Role-based permission system that enforces access controls based on user roles (owner vs. participant) and designated moderator status | |
- **Data Layer**: | |
- API services and network communication | |
- Local storage and caching mechanisms | |
- Repository implementations | |
- **DI Layer**: | |
- HILT modules for dependency provision | |
- Scoped components for proper lifecycle management | |
--- | |
### **Security and Privacy** | |
- **User Data**: The application collects and processes user information with strict privacy measures | |
- **Location Data**: User locations are processed only with explicit permission and for intended functionality | |
- **Media Content**: User-uploaded content is stored securely with appropriate access controls | |
- **Authentication**: Industry-standard authentication and session management practices are implemented | |
- **Role-Based Access Control**: The application implements a fine-grained permission system where adventure owners have full control, moderators can invite others, and privacy settings (public, friends only, by invitation) further restrict content visibility | |
--- | |
### **Contacts and Support** | |
For questions, support, or business inquiries about Div Adventures, please contact the development team at: | |
- **Email**: [email protected] | |
- **Website**: www.divadventures.com | |
--- | |
### **Device Capabilities** | |
Div Adventures takes advantage of modern Android device features: | |
- **Camera**: For capturing and sharing adventure photos | |
- **GPS and Location Services**: For adventure geolocation tagging and discovery | |
- **Network Connectivity**: For social features and content sharing | |
- **Sensors**: Optional integration with device sensors for enhanced adventure tracking | |
- **Storage**: For caching adventure content and user data | |
--- | |
### **Performance Considerations** | |
The app is optimized for performance and resource efficiency: | |
- **Image Compression**: Optimizes uploaded photos for bandwidth and storage | |
- **Lazy Loading**: Implements lazy loading of adventure content for smooth scrolling | |
- **Background Processing**: Handles intensive tasks in background threads | |
- **Efficient API Communication**: Uses caching and batched requests to minimize network usage | |
- **Memory Management**: Implements proper lifecycle management to prevent memory leaks | |
--- | |
### **Navigation Architecture** | |
The app implements a sophisticated navigation system that ensures seamless user experience across different screens and features: | |
#### **Core Navigation Components** | |
1. **Screen Routes**: | |
- Defined as sealed classes with static route patterns | |
- Support for parameterized routes with dynamic segments (e.g., profile IDs, adventure data) | |
- Clean separation between route definition and navigation logic | |
2. **Navigation Events**: | |
- Event-based navigation model with typed navigation actions | |
- Support for complex navigation patterns including: | |
- Standard navigation with backstack management | |
- Deep linking to specific destinations | |
- Passing complex data objects between screens | |
- Pop operations with granular control | |
3. **Transition Animations**: | |
- Custom animations for screen transitions | |
- Slide and fade effects with configurable timing and easing | |
- Distinct animations for forward navigation vs. back navigation | |
#### **Navigation Implementation** | |
1. **MyNavHost**: | |
- Central navigation component that defines the app's navigation graph | |
- Handles route registration and screen composition | |
- Manages transition animations between destinations | |
2. **Main Navigation Flow**: | |
- Three-tab bottom navigation structure (Home, Add, Profile) | |
- Tab-specific sub-navigation with independent backstacks | |
- Smooth transitions between main sections | |
3. **Custom Navigation Stack Manager**: | |
- Provides enhanced control over the navigation backstack | |
- Supports complex navigation patterns not available in standard Navigation components | |
- Maintains history state for advanced navigation scenarios | |
4. **Navigation Event Handling**: | |
- Navigation events are processed through a central event channel | |
- Supports navigation with destination change listeners | |
- Handles navigation with complex data objects using URI encoding | |
5. **Deep Linking**: | |
- Support for navigating directly to specific content (profiles, adventures) | |
- Secure parameter passing for complex data objects | |
- Error handling to prevent navigation failures | |
--- | |
For additional details about specific parts of the project, please contact the development team. | |
``` | |
├── app | |
│ └── src | |
│ └── main | |
│ └── java | |
│ └── com | |
│ └── divadventure | |
│ ├── data | |
│ │ ├── navigation | |
│ │ │ ├── Navigation.kt | |
│ │ │ ├── NavigationEvent.kt | |
│ │ │ └── NavigationViewModel.kt | |
│ │ ├── Repository | |
│ │ │ ├── AdventureRepository.kt | |
│ │ │ ├── CalendarRepository.kt | |
│ │ │ ├── InterestsRepository.kt | |
│ │ │ ├── ProfileRepository.kt | |
│ │ │ ├── RequestsRepository.kt | |
│ │ │ ├── UploadImageManager.kt | |
│ │ │ └── UsersRepository.kt | |
│ │ └── SharedService.kt | |
│ ├── di | |
│ │ ├── AppModule.kt | |
│ │ ├── FeaturesModules | |
│ │ │ ├── AdventuresModule.kt | |
│ │ │ ├── DateModule.kt | |
│ │ │ ├── LocationModule.kt | |
│ │ │ ├── ProfileModule.kt | |
│ │ │ └── RequestsModule.kt | |
│ │ ├── HomeModule.kt | |
│ │ ├── NetworkModule.kt | |
│ │ ├── SharedPreferencesModule.kt | |
│ │ └── ViewModelModules | |
│ ├── domain | |
│ │ ├── models | |
│ │ │ ├── AdventureModels.kt | |
│ │ │ ├── AuthModels.kt | |
│ │ │ ├── Requests.kt | |
│ │ │ └── UsersModels.kt | |
│ │ └── usecase | |
│ │ ├── AdventuresUseCase.kt | |
│ │ ├── CalendarUseCase.kt | |
│ │ ├── InterestsUseCase.kt | |
│ │ ├── LocationsUseCase.kt | |
│ │ ├── NotificationsUseCase.kt | |
│ │ ├── RequestsUseCase.kt | |
│ │ ├── TasksUseCase.kt | |
│ │ └── UsersUseCase.kt | |
│ ├── ui | |
│ │ ├── AddShared.kt | |
│ │ ├── AuthShared.kt | |
│ │ ├── HomeShared.kt | |
│ │ ├── ManageShared.kt | |
│ │ └── ProfileShared.kt | |
│ └── viewmodel | |
│ ├── AuthViewModel.kt | |
│ ├── BaseViewModel.kt | |
│ ├── HomeViewModel.kt | |
│ ├── MainViewModel.kt | |
│ ├── ManageAdventureViewModel.kt | |
│ ├── NotificationsViewModel.kt | |
│ └── ProfileViewModel.kt | |
└── readme.md |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment