Created
June 13, 2025 04:02
-
-
Save sajjadyousefnia/a5509f6ea7f41a1764c123b2f21129b3 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\App.kt | |
```kt | |
package com.divadventure | |
import android.app.Application | |
import android.content.Context | |
import android.net.ConnectivityManager | |
import android.net.NetworkCapabilities | |
import android.os.Build | |
import com.divadventure.BuildConfig | |
import com.divadventure.App.Companion.weakContext | |
import com.google.android.libraries.places.api.Places | |
import dagger.hilt.android.HiltAndroidApp | |
import timber.log.Timber | |
import java.lang.ref.WeakReference | |
@HiltAndroidApp | |
class App : Application() { | |
override fun onCreate() { | |
super.onCreate() | |
weakContext = WeakReference(this) | |
Places.initialize(applicationContext, "AIzaSyCFUxI4_4TdJvF_piOdkifFRGa2SuH3W3U") | |
Timber.i("Application onCreate called") | |
if (BuildConfig.DEBUG) { | |
Timber.plant(Timber.DebugTree()) | |
Timber.d("Timber DebugTree planted for logging") | |
} else { | |
Timber.w("Timber is not planted as the app is in Release mode") | |
} | |
} | |
companion object { | |
private var weakContext: WeakReference<Context>? = null | |
fun getContext(): Context? { | |
return weakContext?.get() | |
} | |
fun isNetworkConnected(context: Context): Boolean { | |
val connectivityManager = | |
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager | |
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { | |
val network = connectivityManager.activeNetwork ?: return false | |
val activeNetwork = | |
connectivityManager.getNetworkCapabilities(network) ?: return false | |
return when { | |
activeNetwork.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> true | |
activeNetwork.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> true | |
activeNetwork.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> true | |
else -> false | |
} | |
} else { | |
val networkInfo = connectivityManager.activeNetworkInfo ?: return false | |
return networkInfo.isConnected | |
} | |
} | |
} | |
} | |
``` | |
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\MainActivity.kt | |
```kt | |
package com.divadventure | |
import android.app.Activity | |
import android.os.Build | |
import android.os.Bundle | |
import androidx.activity.ComponentActivity | |
import androidx.activity.SystemBarStyle | |
import androidx.activity.compose.setContent | |
import androidx.activity.enableEdgeToEdge | |
import androidx.compose.foundation.layout.fillMaxSize | |
import androidx.compose.material3.MaterialTheme | |
import androidx.compose.material3.Scaffold | |
import androidx.compose.material3.Surface | |
import androidx.compose.runtime.Composable | |
import androidx.compose.runtime.SideEffect | |
import androidx.compose.ui.Modifier | |
import androidx.compose.ui.graphics.Color | |
import androidx.compose.ui.graphics.toArgb | |
import androidx.compose.ui.platform.LocalView | |
import androidx.core.view.WindowCompat | |
import com.divadventure.data.navigation.MyNavHost | |
import com.divadventure.ui.theme.DivAdventureTheme | |
import dagger.hilt.android.AndroidEntryPoint | |
@AndroidEntryPoint | |
class MainActivity : ComponentActivity() { | |
override fun onCreate(savedInstanceState: Bundle?) { | |
super.onCreate(savedInstanceState) | |
enableEdgeToEdge( | |
statusBarStyle = SystemBarStyle.auto( | |
Color.White.hashCode(), | |
Color.White.hashCode() | |
), | |
navigationBarStyle = SystemBarStyle.auto( | |
Color.White.hashCode(), | |
Color.White.hashCode() | |
) | |
) | |
// Tell the system to let our app draw behind the system bars | |
WindowCompat.setDecorFitsSystemWindows(window, true) | |
setContent { | |
DivAdventureTheme { | |
SetStatusBarAndNavigationColors( | |
statusBarIconsColor = Color.Black, | |
navigationKeysColor = Color.Black | |
) | |
Surface( | |
modifier = Modifier.fillMaxSize(), | |
color = MaterialTheme.colorScheme.background | |
) { | |
Scaffold(modifier = Modifier.fillMaxSize()) { padding -> | |
MyNavHost(padding) | |
} } | |
} | |
} | |
} | |
} | |
@Composable | |
fun SetStatusBarAndNavigationColors( | |
statusBarIconsColor: Color, | |
navigationKeysColor: Color | |
) { | |
val view = LocalView.current | |
val window = (view.context as Activity).window | |
val windowInsetsController = WindowCompat.getInsetsController(window, view) | |
SideEffect { | |
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { | |
// Set status bar color | |
window.statusBarColor = Color.Transparent.toArgb() | |
// Set navigation bar color | |
window.navigationBarColor = Color.Transparent.toArgb() | |
} | |
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { | |
windowInsetsController.isAppearanceLightStatusBars = statusBarIconsColor == Color.Black | |
} | |
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { | |
windowInsetsController.isAppearanceLightNavigationBars = | |
navigationKeysColor == Color.Black | |
} | |
} | |
} | |
``` | |
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\components\AdventureActionButton.kt | |
```kt | |
package com.divadventure.ui.components | |
import androidx.compose.foundation.layout.padding | |
import androidx.compose.material3.ButtonDefaults | |
import androidx.compose.material3.Text | |
import androidx.compose.material3.TextButton | |
import androidx.compose.runtime.Composable | |
import androidx.compose.ui.Modifier | |
import androidx.compose.ui.graphics.Color | |
import androidx.compose.ui.text.font.FontWeight | |
import androidx.compose.ui.unit.dp | |
import com.divadventure.domain.models.Adventure | |
import com.divadventure.domain.models.AdventureType | |
import com.divadventure.domain.usecase.AdventuresUseCase | |
import com.divadventure.viewmodel.ManageAdventureViewModel | |
/** | |
* A reusable adventure action button that changes color and behavior based on adventure type | |
*/ | |
@Composable | |
fun AdventureActionButton( | |
adventure: Adventure, | |
adventuresUseCase: AdventuresUseCase, | |
viewModel: ManageAdventureViewModel, | |
modifier: Modifier = Modifier | |
) { | |
// Determine the adventure type | |
val adventureType = adventuresUseCase.checkAdventureType(adventure) | |
// Get the appropriate color for this adventure type | |
val buttonColor = when (adventureType) { | |
AdventureType.Manage -> Color(0xFFB175FF) // Indigo | |
AdventureType.Join -> Color(0xFF30D158) // Green | |
AdventureType.Going -> Color(0xFF0A84FF) // Orange | |
AdventureType.Pending -> Color(0xffFFCC00) // Deep Orange | |
AdventureType.Leave -> Color(0xFFF2F2F7) // Red | |
} | |
// Get the appropriate text for this adventure type | |
val buttonText = when (adventureType) { | |
AdventureType.Manage -> "Manage" | |
AdventureType.Join -> "Join" | |
AdventureType.Going -> "Going" | |
AdventureType.Pending -> "Pending" | |
AdventureType.Leave -> "Leave" | |
} | |
TextButton( | |
onClick = { viewModel.performActionByType(adventureType, adventure.id) }, | |
colors = ButtonDefaults.textButtonColors( | |
contentColor = buttonColor | |
), | |
modifier = modifier.padding(horizontal = 8.dp) | |
) { | |
Text( | |
text = buttonText, | |
fontWeight = FontWeight.SemiBold | |
) | |
} | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\ui\components\AdventureItem.kt | |
```kt | |
package com.divadventure.ui.components | |
import androidx.compose.foundation.layout.Column | |
import androidx.compose.foundation.layout.Row | |
import androidx.compose.foundation.layout.Spacer | |
import androidx.compose.foundation.layout.fillMaxWidth | |
import androidx.compose.foundation.layout.height | |
import androidx.compose.foundation.layout.padding | |
import androidx.compose.material3.Card | |
import androidx.compose.material3.CardDefaults | |
import androidx.compose.material3.MaterialTheme | |
import androidx.compose.material3.Text | |
import androidx.compose.runtime.Composable | |
import androidx.compose.runtime.remember | |
import androidx.compose.ui.Alignment | |
import androidx.compose.ui.Modifier | |
import androidx.compose.ui.text.font.FontWeight | |
import androidx.compose.ui.unit.dp | |
import com.divadventure.domain.models.Adventure | |
import com.divadventure.domain.models.AdventureType | |
import com.divadventure.domain.usecase.AdventuresUseCase | |
import com.divadventure.viewmodel.ManageAdventureViewModel | |
/** | |
* Adventure item card that includes a type-specific action button | |
* The type checking is performed by the view model, not inside this component | |
*/ | |
@Composable | |
fun AdventureItem( | |
adventure: Adventure, | |
viewModel: ManageAdventureViewModel, | |
modifier: Modifier = Modifier | |
) { | |
// Use the ViewModel to determine the adventure type | |
val adventureType = | |
remember(adventure.id) { viewModel.getAdventureType(adventure) } as AdventureType | |
Card( | |
modifier = modifier.fillMaxWidth(), | |
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) | |
) { | |
Column(modifier = Modifier.padding(16.dp)) { | |
// Adventure title | |
Text( | |
text = adventure.title, | |
style = MaterialTheme.typography.titleLarge, | |
fontWeight = FontWeight.Bold | |
) | |
Spacer(modifier = Modifier.height(8.dp)) | |
// Adventure description | |
Text( | |
text = adventure.description, | |
style = MaterialTheme.typography.bodyMedium, | |
maxLines = 2 | |
) | |
Spacer(modifier = Modifier.height(16.dp)) | |
// Bottom row with action button | |
Row( | |
modifier = Modifier.fillMaxWidth(), | |
verticalAlignment = Alignment.CenterVertically | |
) { | |
Spacer(modifier = Modifier.weight(1f)) | |
// The AdventureTypeButton doesn't directly use UseCase or ViewModel | |
// It just takes the already-determined type and an onClick handler | |
AdventureTypeButton( | |
adventureType = adventureType, | |
onClick = { | |
// Handle button click through the viewModel | |
viewModel.performActionByType(adventureType, adventure.id) | |
} | |
) | |
} | |
} | |
} | |
} | |
/** | |
* Adventure item card with action button that changes based on adventure type | |
*/ | |
@Composable | |
fun AdventureItem( | |
adventure: Adventure, | |
adventuresUseCase: AdventuresUseCase, | |
viewModel: ManageAdventureViewModel, | |
modifier: Modifier = Modifier | |
) { | |
Card( | |
modifier = modifier.fillMaxWidth(), | |
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) | |
) { | |
Column(modifier = Modifier.padding(16.dp)) { | |
// Adventure title | |
Text( | |
text = adventure.title, | |
style = MaterialTheme.typography.titleLarge, | |
fontWeight = FontWeight.Bold | |
) | |
Spacer(modifier = Modifier.height(8.dp)) | |
// Adventure description | |
Text( | |
text = adventure.description, | |
style = MaterialTheme.typography.bodyMedium, | |
maxLines = 2 | |
) | |
Spacer(modifier = Modifier.height(16.dp)) | |
// Bottom row with action button | |
Row( | |
modifier = Modifier.fillMaxWidth(), | |
verticalAlignment = Alignment.CenterVertically | |
) { | |
Spacer(modifier = Modifier.weight(1f)) | |
// The action button that changes color and behavior based on adventure type | |
AdventureActionButton( | |
adventure = adventure, | |
adventuresUseCase = adventuresUseCase, | |
viewModel = viewModel | |
) | |
} | |
} | |
} | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\ui\components\AdventureTypeButton.kt | |
```kt | |
package com.divadventure.ui.components | |
import androidx.compose.foundation.layout.padding | |
import androidx.compose.material3.ButtonDefaults | |
import androidx.compose.material3.Text | |
import androidx.compose.material3.TextButton | |
import androidx.compose.runtime.Composable | |
import androidx.compose.ui.Modifier | |
import androidx.compose.ui.graphics.Color | |
import androidx.compose.ui.text.font.FontWeight | |
import androidx.compose.ui.unit.dp | |
import com.divadventure.domain.models.AdventureType | |
/** | |
* A pure UI component for rendering a button styled according to adventure type | |
* This component doesn't depend on use cases or view models directly | |
*/ | |
@Composable | |
fun AdventureTypeButton( | |
adventureType: AdventureType, | |
onClick: () -> Unit, | |
modifier: Modifier = Modifier | |
) { | |
// Get the appropriate color for this adventure type | |
val buttonColor = when(adventureType) { | |
AdventureType.Manage -> Color(0xFF3F51B5) // Indigo | |
AdventureType.Join -> Color(0xFF4CAF50) // Green | |
AdventureType.Going -> Color(0xFFFF9800) // Orange | |
AdventureType.Pending -> Color(0xFFFF5722) // Deep Orange | |
AdventureType.Leave -> Color(0xFFF44336) // Red | |
} | |
// Get the appropriate text for this adventure type | |
val buttonText = when(adventureType) { | |
AdventureType.Manage -> "Manage" | |
AdventureType.Join -> "Join" | |
AdventureType.Going -> "Going" | |
AdventureType.Pending -> "Pending" | |
AdventureType.Leave -> "Leave" | |
} | |
TextButton( | |
onClick = onClick, | |
colors = ButtonDefaults.textButtonColors( | |
contentColor = buttonColor | |
), | |
modifier = modifier.padding(horizontal = 8.dp) | |
) { | |
Text( | |
text = buttonText, | |
fontWeight = FontWeight.SemiBold | |
) | |
} | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\ui\components\StaticMap.kt | |
```kt | |
package com.divadventure.ui.components | |
import androidx.compose.foundation.layout.aspectRatio | |
import androidx.compose.foundation.layout.fillMaxWidth | |
import androidx.compose.foundation.shape.RoundedCornerShape | |
import androidx.compose.runtime.Composable | |
import androidx.compose.ui.Modifier | |
import androidx.compose.ui.draw.clip | |
import androidx.compose.ui.unit.dp | |
import com.google.android.gms.maps.model.CameraPosition | |
import com.google.android.gms.maps.model.LatLng | |
import com.google.maps.android.compose.GoogleMap | |
import com.google.maps.android.compose.MapProperties | |
import com.google.maps.android.compose.MapUiSettings | |
import com.google.maps.android.compose.Marker | |
import com.google.maps.android.compose.MarkerState | |
import com.google.maps.android.compose.rememberCameraPositionState | |
/** | |
* A non-interactive Google Map composable that displays a static view of a location. | |
* | |
* @param latitude The latitude coordinate to center the map on | |
* @param longitude The longitude coordinate to center the map on | |
* @param modifier Optional modifier for customizing the map's appearance | |
* @param zoomLevel The zoom level for the map (default: 13f) | |
*/ | |
@Composable | |
fun StaticMap( | |
latitude: Double, | |
longitude: Double, | |
modifier: Modifier = Modifier, | |
zoomLevel: Float = 13f | |
) { | |
val location = LatLng(latitude, longitude) | |
// Set up camera position to focus on the provided coordinates | |
val cameraPositionState = rememberCameraPositionState { | |
position = CameraPosition.fromLatLngZoom(location, zoomLevel) | |
} | |
// Disable UI controls and gestures | |
val uiSettings = MapUiSettings( | |
zoomControlsEnabled = false, | |
scrollGesturesEnabled = false, | |
zoomGesturesEnabled = false, | |
tiltGesturesEnabled = false, | |
rotationGesturesEnabled = false, | |
myLocationButtonEnabled = false, | |
compassEnabled = false, | |
indoorLevelPickerEnabled = false, | |
mapToolbarEnabled = false, | |
scrollGesturesEnabledDuringRotateOrZoom = false | |
) | |
val mapProperties = MapProperties(maxZoomPreference = zoomLevel, minZoomPreference = zoomLevel) | |
GoogleMap( | |
modifier = modifier | |
.fillMaxWidth() | |
.aspectRatio(1f) // 1:1 aspect ratio | |
.clip(RoundedCornerShape(8.dp)), | |
cameraPositionState = cameraPositionState, | |
uiSettings = uiSettings, | |
properties = mapProperties | |
) { | |
// Add a marker at the specified location | |
Marker( | |
state = MarkerState(position = location), | |
title = "Selected Location" | |
) | |
} | |
} | |
``` | |
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.Image | |
import androidx.compose.foundation.background | |
import androidx.compose.foundation.layout.Column | |
import androidx.compose.foundation.layout.Row | |
import androidx.compose.foundation.layout.Spacer | |
import androidx.compose.foundation.layout.fillMaxSize | |
import androidx.compose.foundation.layout.fillMaxWidth | |
import androidx.compose.foundation.layout.padding | |
import androidx.compose.foundation.layout.size | |
import androidx.compose.foundation.layout.width | |
import androidx.compose.foundation.shape.CircleShape | |
import androidx.compose.foundation.shape.RoundedCornerShape | |
import androidx.compose.material3.Button | |
import androidx.compose.material3.ButtonDefaults | |
import androidx.compose.material3.Card | |
import androidx.compose.material3.CardDefaults | |
import androidx.compose.material3.Text | |
import androidx.compose.runtime.Composable | |
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.platform.LocalDensity | |
import androidx.compose.ui.res.painterResource | |
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 androidx.compose.ui.unit.sp | |
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, | |
) | |
) | |
} | |
// -- Sample Data Model -- | |
enum class FriendStatus { REMOVED, PENDING, ADD } | |
data class Friend( | |
val name: String, | |
val username: String, | |
val imageRes: Int, // Replace with your image resource | |
val status: FriendStatus | |
) | |
// -- Sample List -- | |
val friends = listOf( | |
Friend("Babak Basseri", "@babak-ba", R.drawable.random_image_1, FriendStatus.REMOVED), | |
Friend("Barbod Sharif", "@barbodddd", R.drawable.random_image_2, FriendStatus.REMOVED), | |
Friend("Barin Ahmadi", "@barin", R.drawable.random_image_3, FriendStatus.PENDING), | |
Friend("Barin Ahmadi", "@barin", R.drawable.random_image_3, FriendStatus.REMOVED), | |
Friend("Barbod Sharif", "@barbodddd", R.drawable.random_image_2, FriendStatus.ADD), | |
Friend("Barin Ahmadi", "@barin", R.drawable.random_image_3, FriendStatus.REMOVED), | |
Friend("Barin Ahmadi", "@barin", R.drawable.random_image_3, FriendStatus.REMOVED), | |
Friend("Barin Ahmadi", "@barin", R.drawable.random_image_3, FriendStatus.REMOVED), | |
Friend("Barin Ahmadi", "@barin", R.drawable.random_image_3, FriendStatus.REMOVED), | |
) | |
@Composable | |
fun FriendList(friends: List<Friend>) { | |
Column(modifier = Modifier | |
.fillMaxSize() | |
.background(Color(0xFFF8F9FB))) { | |
friends.forEach { friend -> | |
FriendItem(friend) | |
} | |
} | |
} | |
@Composable | |
fun FriendItem(friend: Friend) { | |
Card( | |
modifier = Modifier | |
.fillMaxWidth() | |
.padding(horizontal = 8.dp, vertical = 4.dp), | |
shape = RoundedCornerShape(12.dp), | |
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) | |
) { | |
Row( | |
modifier = Modifier | |
.fillMaxWidth() | |
.padding(12.dp), | |
verticalAlignment = Alignment.CenterVertically | |
) { | |
Image( | |
painter = painterResource(id = friend.imageRes), | |
contentDescription = null, | |
modifier = Modifier | |
.size(48.dp) | |
.clip(CircleShape) | |
) | |
Spacer(modifier = Modifier.width(14.dp)) | |
Column(modifier = Modifier.weight(1f)) { | |
Text( | |
text = friend.name, | |
fontWeight = FontWeight.Bold, | |
fontSize = 16.sp | |
) | |
Text( | |
text = friend.username, | |
color = Color.Gray, | |
fontSize = 13.sp | |
) | |
} | |
Spacer(modifier = Modifier.width(10.dp)) | |
FriendActionButton(friend.status) | |
} | |
} | |
} | |
@Composable | |
fun FriendActionButton(status: FriendStatus) { | |
when (status) { | |
FriendStatus.REMOVED -> Button( | |
onClick = { /*TODO*/ }, | |
colors = ButtonDefaults.buttonColors( | |
containerColor = Color(0xFFF6F7FB), | |
contentColor = Color.Gray | |
), | |
shape = RoundedCornerShape(6.dp) | |
) { | |
Text("Remove Friend") | |
} | |
FriendStatus.PENDING -> Button( | |
onClick = { /*TODO*/ }, | |
colors = ButtonDefaults.buttonColors( | |
containerColor = Color(0xFFFF9800), | |
contentColor = Color.White | |
), | |
shape = RoundedCornerShape(6.dp) | |
) { | |
Text("Pending") | |
} | |
FriendStatus.ADD -> Button( | |
onClick = { /*TODO*/ }, | |
colors = ButtonDefaults.buttonColors( | |
containerColor = Color(0xFF19C37D), | |
contentColor = Color.White | |
), | |
shape = RoundedCornerShape(6.dp) | |
) { | |
Text("Add Friend") | |
} | |
} | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\ui\otp\ModifierExt.kt | |
```kt | |
package com.divadventure.ui.otp | |
import androidx.compose.ui.Modifier | |
import androidx.compose.ui.graphics.Color | |
import androidx.compose.ui.unit.Dp | |
import androidx.compose.ui.unit.dp | |
import androidx.compose.ui.draw.drawBehind | |
import androidx.compose.ui.geometry.Offset | |
import androidx.compose.ui.unit.dp | |
/** | |
* Creates a Modifier that adds a bottom stroke to a Composable. | |
* | |
* @param color The color of the stroke. | |
* @param strokeWidth The thickness of the stroke. | |
* @return A Modifier that draws a bottom stroke. | |
*/ | |
fun Modifier.bottomStroke(color: Color, strokeWidth: Dp = 2.dp): Modifier = this.then( | |
Modifier.drawBehind { | |
val strokePx = strokeWidth.toPx() | |
// Draw a line at the bottom | |
drawLine( | |
color = color, | |
start = Offset(x = 0f, y = size.height - strokePx / 2), | |
end = Offset(x = size.width, y = size.height - strokePx / 2), | |
strokeWidth = strokePx | |
) | |
} | |
) | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\ui\otp\OtpInputField.kt | |
```kt | |
package com.divadventure.ui.otp | |
import androidx.compose.foundation.background | |
import androidx.compose.foundation.border | |
import androidx.compose.foundation.layout.Arrangement | |
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.padding | |
import androidx.compose.foundation.layout.size | |
import androidx.compose.foundation.shape.RoundedCornerShape | |
import androidx.compose.foundation.text.BasicTextField | |
import androidx.compose.foundation.text.KeyboardActions | |
import androidx.compose.foundation.text.KeyboardOptions | |
import androidx.compose.material3.MaterialTheme | |
import androidx.compose.runtime.Composable | |
import androidx.compose.runtime.LaunchedEffect | |
import androidx.compose.runtime.MutableState | |
import androidx.compose.runtime.mutableStateOf | |
import androidx.compose.runtime.remember | |
import androidx.compose.runtime.rememberCoroutineScope | |
import androidx.compose.ui.Alignment | |
import androidx.compose.ui.Modifier | |
import androidx.compose.ui.focus.FocusRequester | |
import androidx.compose.ui.focus.focusRequester | |
import androidx.compose.ui.graphics.Color | |
import androidx.compose.ui.layout.onGloballyPositioned | |
import androidx.compose.ui.platform.LocalConfiguration | |
import androidx.compose.ui.platform.LocalDensity | |
import androidx.compose.ui.platform.LocalFocusManager | |
import androidx.compose.ui.platform.LocalSoftwareKeyboardController | |
import androidx.compose.ui.platform.testTag | |
import androidx.compose.ui.text.TextRange | |
import androidx.compose.ui.text.input.ImeAction | |
import androidx.compose.ui.text.input.KeyboardType | |
import androidx.compose.ui.text.input.PasswordVisualTransformation | |
import androidx.compose.ui.text.input.TextFieldValue | |
import androidx.compose.ui.text.input.VisualTransformation | |
import androidx.compose.ui.text.style.TextAlign | |
import androidx.compose.ui.tooling.preview.Preview | |
import androidx.compose.ui.unit.Dp | |
import androidx.compose.ui.unit.dp | |
import kotlinx.coroutines.launch | |
/** | |
* Data class representing an individual OTP field. | |
* | |
* @property text The text content of the OTP field. | |
* @property focusRequester A FocusRequester to manage focus on the field. | |
*/ | |
private data class OtpField( | |
val text: String, | |
val index: Int, | |
val focusRequester: FocusRequester? = null | |
) | |
/** | |
* A Composable function that creates a row of OTP input fields based on the specified count. | |
* Each field supports custom visual modifications and can handle input in various formats | |
* depending on the specified keyboard type. The function manages the creation and updating | |
* of these fields dynamically based on the current OTP value and provides functionality | |
* for managing focus transitions between the fields. | |
* | |
* @param otp A mutable state holding the current OTP value. This state is observed for changes | |
* to update the individual fields and to reset focus as necessary. | |
* @param count The number of OTP input boxes to display. This defines how many individual | |
* fields will be generated and managed. | |
* @param otpBoxModifier A Modifier passed to each OTP input box for custom styling and behavior. | |
* It allows for adjustments such as size or specific visual effects. | |
* Note: Avoid adding padding directly to `otpBoxModifier` as it may interfere | |
* with the layout calculations for the OTP fields. If padding is necessary, | |
* consider applying it to surrounding elements or within the `OtpBox` composable. | |
* @param otpTextType The type of keyboard to display when a field is focused, typically set to | |
* KeyboardType.Number for OTP inputs. This can be adjusted if alphanumeric | |
* OTPs are required. | |
* @param textColor The color used for the text within each OTP box, allowing for visual customization. | |
* | |
* The function sets up each input field with its own state and focus requester, managing | |
* internal state updates in response to changes in the OTP value and user interactions. | |
* The layout is organized as a horizontal row of text fields, with each field designed to | |
* capture a single character of the OTP. Focus automatically advances to the next field upon | |
* input, and if configured, input characters can be visually obscured for security. | |
* | |
* Example usage: | |
* ```kotlin | |
* OtpInputField( | |
* otp = remember { mutableStateOf("12345") }, | |
* count = 5, | |
* otpBoxModifier = Modifier.border(1.dp, Color.Black).background(Color.White), | |
* otpTextType = KeyboardType.Number, | |
* textColor = Color.Black // Setting the text color to black | |
* ) | |
* ``` | |
* This example sets up an OTP field with a basic black border and white background, without padding. | |
*/ | |
@Composable | |
fun OtpInputField( | |
otp: MutableState<String>, // The current OTP value. | |
count: Int = 5, // Number of OTP boxes. | |
otpBoxModifier: Modifier = Modifier | |
.border(1.pxToDp(), Color.Gray) | |
.background(Color.White), | |
otpTextType: KeyboardType = KeyboardType.Number, | |
textColor: Color = Color.Black | |
) { | |
val scope = rememberCoroutineScope() | |
// Initialize state for each OTP box with its character and optional focus requester. | |
val otpFieldsValues = remember { | |
(0 until count).mapIndexed { index, i -> | |
mutableStateOf( | |
OtpField( | |
text = otp.value.getOrNull(i)?.toString() ?: "", | |
index = index, | |
focusRequester = FocusRequester() | |
) | |
) | |
} | |
} | |
// Update each OTP box's value when the overall OTP value changes, and manage focus. | |
LaunchedEffect(key1 = otp.value) { | |
for (i in otpFieldsValues.indices) { | |
otpFieldsValues[i].value = | |
otpFieldsValues[i].value.copy(otp.value.getOrNull(i)?.toString() ?: "") | |
} | |
// Request focus on the first box if the OTP is blank (e.g., reset). | |
if (otp.value.isBlank()) { | |
try { | |
otpFieldsValues[0].value.focusRequester?.requestFocus() | |
} catch (e: Exception) { | |
e.printStackTrace() | |
} | |
} | |
} | |
// Create a row of OTP boxes. | |
Row( | |
modifier = Modifier.fillMaxWidth(), | |
horizontalArrangement = Arrangement.SpaceEvenly | |
) { | |
repeat(count) { index -> | |
// For each OTP box, manage its value, focus, and what happens on value change. | |
OtpBox( | |
modifier = otpBoxModifier, | |
otpValue = otpFieldsValues[index].value, | |
textType = otpTextType, | |
textColor = textColor, | |
isLastItem = index == count - 1, // Check if this box is the last in the sequence. | |
totalBoxCount = count, | |
onValueChange = { newValue -> | |
// Handling logic for input changes, including moving focus and updating OTP state. | |
scope.launch { | |
handleOtpInputChange(index, count, newValue, otpFieldsValues, otp) | |
} | |
}, | |
onFocusSet = { focusRequester -> | |
// Save the focus requester for each box to manage focus programmatically. | |
otpFieldsValues[index].value = | |
otpFieldsValues[index].value.copy(focusRequester = focusRequester) | |
}, | |
onNext = { | |
// Attempt to move focus to the next box when the "next" action is triggered. | |
focusNextBox(index, count, otpFieldsValues) | |
}, | |
) | |
} | |
} | |
} | |
/** | |
* Handles input changes for each OTP box and manages the logic for updating the OTP state | |
* and managing focus transitions between OTP boxes. | |
* | |
* @param index The index of the OTP box where the input change occurred. | |
* @param count The total number of OTP boxes. | |
* @param newValue The new value inputted into the OTP box at the specified index. | |
* @param otpFieldsValues A list of mutable states, each representing an individual OTP box's state. | |
* @param otp A mutable state holding the current concatenated value of all OTP boxes. | |
* | |
* The function updates the text of the targeted OTP box based on the length and content of `newValue`. | |
* If `newValue` contains only one character, it replaces the existing text in the current box. | |
* If two characters are present, likely from rapid user input, it sets the box's text to the second character, | |
* assuming the first character was already accepted. If multiple characters are pasted, | |
* they are distributed across the subsequent boxes starting from the current index. | |
* | |
* Focus management is also handled, where focus is moved to the next box if a single character is inputted, | |
* and moved back to the previous box if the current box is cleared. This is especially useful for | |
* scenarios where users might quickly navigate between OTP fields either by typing or deleting characters. | |
* | |
* Exception handling is used to catch and log any errors that occur during focus management to avoid | |
* crashing the application and to provide debug information. | |
*/ | |
private fun handleOtpInputChange( | |
index: Int, | |
count: Int, | |
newValue: String, | |
otpFieldsValues: List<MutableState<OtpField>>, | |
otp: MutableState<String> | |
) { | |
// Handle input for the current box. | |
if (newValue.length <= 1) { | |
// Directly set the new value if it's a single character. | |
otpFieldsValues[index].value = otpFieldsValues[index].value.copy(text = newValue) | |
} else if (newValue.length == 2) { | |
// If length of new value is 2, we can guess the user is typing focusing on current box | |
// In this case set the unmatched character only | |
otpFieldsValues[index].value = | |
otpFieldsValues[index].value.copy(text = newValue.lastOrNull()?.toString() ?: "") | |
} else if (newValue.isNotEmpty()) { | |
// If pasting multiple characters, distribute them across the boxes starting from the current index. | |
newValue.forEachIndexed { i, char -> | |
if (index + i < count) { | |
otpFieldsValues[index + i].value = | |
otpFieldsValues[index + i].value.copy(text = char.toString()) | |
} | |
} | |
} | |
// Update the overall OTP state. | |
var currentOtp = "" | |
otpFieldsValues.forEach { | |
currentOtp += it.value.text | |
} | |
try { | |
// Logic to manage focus. | |
if (newValue.isEmpty() && index > 0) { | |
// If clearing a box and it's not the first box, move focus to the previous box. | |
otpFieldsValues.getOrNull(index - 1)?.value?.focusRequester?.requestFocus() | |
} else if (index < count - 1 && newValue.isNotEmpty()) { | |
// If adding a character and not on the last box, move focus to the next box. | |
otpFieldsValues.getOrNull(index + 1)?.value?.focusRequester?.requestFocus() | |
} | |
} catch (e: Exception) { | |
e.printStackTrace() | |
} | |
otp.value = currentOtp | |
} | |
private fun focusNextBox( | |
index: Int, | |
count: Int, | |
otpFieldsValues: List<MutableState<OtpField>> | |
) { | |
if (index + 1 < count) { | |
// Move focus to the next box if the current one is filled and it's not the last box. | |
try { | |
otpFieldsValues[index + 1].value.focusRequester?.requestFocus() | |
} catch (e: Exception) { | |
e.printStackTrace() | |
} | |
} | |
} | |
@Composable | |
private fun OtpBox( | |
modifier: Modifier, | |
otpValue: OtpField, // Current value of this OTP box. | |
textType: KeyboardType = KeyboardType.Number, | |
textColor: Color = Color.Black, | |
isLastItem: Boolean, // Whether this box is the last in the sequence. | |
totalBoxCount: Int, // Total number of OTP boxes for layout calculations. | |
onValueChange: (String) -> Unit, // Callback for when the value changes. | |
onFocusSet: (FocusRequester) -> Unit, // Callback to set focus requester. | |
onNext: () -> Unit, // Callback for handling "next" action, typically moving focus forward. | |
) { | |
val focusManager = LocalFocusManager.current | |
val focusRequest = otpValue.focusRequester ?: FocusRequester() | |
val keyboardController = LocalSoftwareKeyboardController.current | |
// Calculate the size of the box based on screen width and total count. | |
// If you're using this in Kotlin multiplatform mobile | |
// val screenWidth = LocalWindowInfo.current.containerSize.width | |
// If you're using this in Android | |
val screenWidth = LocalConfiguration.current.screenWidthDp.dp.dpToPx().toInt() | |
val paddingValue = 5 | |
val totalBoxSize = (screenWidth / totalBoxCount) - paddingValue * totalBoxCount | |
Box( | |
modifier = modifier | |
.size(totalBoxSize.pxToDp()), | |
contentAlignment = Alignment.Center, | |
) { | |
BasicTextField( | |
value = TextFieldValue(otpValue.text, TextRange(maxOf(0, otpValue.text.length))), | |
onValueChange = { | |
// Logic to prevent re-triggering onValueChange when focusing. | |
if (!it.text.equals(otpValue)) { | |
onValueChange(it.text) | |
} | |
}, | |
// Setup for focus and keyboard behavior. | |
modifier = Modifier | |
.testTag("otpBox${otpValue.index}") | |
.focusRequester(focusRequest) | |
.onGloballyPositioned { | |
onFocusSet(focusRequest) | |
}, | |
textStyle = MaterialTheme.typography.titleLarge.copy( | |
textAlign = TextAlign.Center, | |
color = textColor | |
), | |
keyboardOptions = KeyboardOptions( | |
keyboardType = textType, | |
imeAction = if (isLastItem) ImeAction.Done else ImeAction.Next | |
), | |
keyboardActions = KeyboardActions( | |
onNext = { | |
onNext() | |
}, | |
onDone = { | |
// Hide keyboard and clear focus when done. | |
keyboardController?.hide() | |
focusManager.clearFocus() | |
} | |
), | |
singleLine = true, | |
visualTransformation = getVisualTransformation(textType), | |
) | |
} | |
} | |
/** | |
* Provides an appropriate VisualTransformation based on the specified keyboard type. | |
* This method is used to determine how text should be displayed in the UI. | |
* | |
* @param textType The type of keyboard input expected, which determines if the text should be obscured. | |
* @return A VisualTransformation that either obscures text for password fields or displays text normally. | |
* Password and NumberPassword fields will have their input obscured with bullet characters. | |
* All other fields will display text as entered. | |
*/ | |
@Composable | |
private fun getVisualTransformation(textType: KeyboardType) = | |
if (textType == KeyboardType.NumberPassword || textType == KeyboardType.Password) PasswordVisualTransformation() else VisualTransformation.None | |
@Composable | |
fun Dp.dpToPx() = with(LocalDensity.current) { [email protected]() } | |
@Composable | |
fun Int.pxToDp() = with(LocalDensity.current) { [email protected]() } | |
@Preview | |
@Composable | |
fun OtpView_Preivew() { | |
MaterialTheme { | |
val otpValue = remember { | |
mutableStateOf("124") | |
} | |
Column( | |
modifier = Modifier.padding(40.pxToDp()), | |
verticalArrangement = Arrangement.spacedBy(20.pxToDp()) | |
) { | |
OtpInputField( | |
otp = otpValue, | |
count = 4, | |
otpBoxModifier = Modifier | |
.border(1.pxToDp(), Color.Black) | |
.background(Color.White), | |
otpTextType = KeyboardType.Number | |
) | |
OtpInputField( | |
otp = otpValue, | |
count = 4, | |
otpTextType = KeyboardType.NumberPassword, | |
otpBoxModifier = Modifier | |
.border(3.pxToDp(), Color.Gray) | |
.background(Color.White) | |
) | |
OtpInputField( | |
otp = otpValue, | |
count = 5, | |
textColor = MaterialTheme.colorScheme.onBackground, | |
otpBoxModifier = Modifier | |
.border(7.pxToDp(), Color(0xFF277F51), shape = RoundedCornerShape(12.pxToDp())) | |
) | |
OtpInputField( | |
otp = otpValue, | |
count = 5, | |
otpBoxModifier = Modifier | |
.bottomStroke(color = Color.DarkGray, strokeWidth = 6.pxToDp()) | |
) | |
} | |
} | |
} | |
``` | |
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\ui\screens\ChangeEmail.kt | |
```kt | |
package com.divadventure.ui.screens | |
import android.os.CountDownTimer | |
import androidx.compose.foundation.background | |
import androidx.compose.foundation.clickable | |
import androidx.compose.foundation.isSystemInDarkTheme | |
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.fillMaxSize | |
import androidx.compose.foundation.layout.fillMaxWidth | |
import androidx.compose.foundation.layout.height | |
import androidx.compose.foundation.layout.padding | |
import androidx.compose.foundation.shape.RoundedCornerShape | |
import androidx.compose.foundation.text.BasicTextField | |
import androidx.compose.foundation.text.KeyboardOptions | |
import androidx.compose.material3.Card | |
import androidx.compose.material3.CardDefaults | |
import androidx.compose.material3.Text | |
import androidx.compose.runtime.Composable | |
import androidx.compose.runtime.LaunchedEffect | |
import androidx.compose.runtime.SideEffect | |
import androidx.compose.runtime.collectAsState | |
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.Alignment | |
import androidx.compose.ui.Modifier | |
import androidx.compose.ui.focus.FocusRequester | |
import androidx.compose.ui.graphics.Color | |
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.style.TextAlign | |
import androidx.compose.ui.unit.dp | |
import androidx.compose.ui.unit.sp | |
import com.divadventure.R | |
import com.divadventure.ui.TopSnackBar | |
import com.divadventure.ui.theme.SystemUIManager | |
import com.divadventure.viewmodel.AuthIntent | |
import com.divadventure.viewmodel.AuthViewModel | |
import com.divadventure.data.navigation.NavigationViewModel | |
import com.divadventure.viewmodel.AuthUiEvent | |
import com.google.accompanist.systemuicontroller.rememberSystemUiController | |
import kotlinx.coroutines.launch | |
import timber.log.Timber | |
@Composable | |
fun ChangeEmail( | |
viewModel: AuthViewModel, navigationViewModel: NavigationViewModel, padding: PaddingValues | |
) { | |
val state by viewModel.state.collectAsState() | |
SystemUIManager( | |
isDarkThemeForBottom = false, | |
isDarkThemeForStatusBar = false, | |
Color.Transparent, | |
Color.Transparent, | |
hideNavigationBar = !state.navigationBarVisibility, | |
hideStatusBar = !state.statusBarVisibility, | |
onSystemBarsVisibilityChange = { statusbar, navigationBar -> | |
/* | |
viewModel.sendIntent( | |
AuthIntent.MutualIntent.ChangeInsetsVisibility( | |
statusbar, navigationBar | |
) | |
) | |
*/ | |
}) | |
val systemUiController = rememberSystemUiController() | |
val darkTheme = isSystemInDarkTheme() | |
SideEffect { | |
systemUiController.setStatusBarColor( | |
color = Color.White, darkIcons = !darkTheme | |
) | |
systemUiController.setNavigationBarColor( | |
color = Color(0xffefeff4), darkIcons = !darkTheme | |
) | |
} | |
Box( | |
modifier = Modifier.background(color = Color(0xffefeff4)) | |
) { | |
var showTopSnackBar by remember { mutableStateOf(false) } | |
var topSnackBarMessage by remember { mutableStateOf("") } | |
var topSnackBarTitle by remember { mutableStateOf("") } | |
val timer = remember { mutableStateOf<CountDownTimer?>(null) } | |
val coroutineScope = rememberCoroutineScope() | |
var showDialog by remember { mutableStateOf(false) } | |
// Handle UiEvents and SnackBar | |
LaunchedEffect(key1 = true) { | |
viewModel.uiEvent.collect { event -> | |
when (event) { | |
AuthUiEvent.AnimateItem -> {} | |
AuthUiEvent.ShowDialog -> { | |
showDialog = true | |
} | |
is AuthUiEvent.ShowSnackbar -> { | |
topSnackBarMessage = event.message | |
topSnackBarTitle = event.title | |
showTopSnackBar = true | |
} | |
is AuthUiEvent.ExecuteNavigation -> { | |
navigationViewModel.navigate( | |
event.navigationEvent | |
) | |
} | |
} | |
} | |
} | |
TopSnackBar( | |
paddingTop = padding.calculateTopPadding(), | |
title = topSnackBarTitle, | |
message = topSnackBarMessage, | |
show = showTopSnackBar, | |
onDismiss = { showTopSnackBar = false }) | |
// Handle timer and auto-dismiss | |
LaunchedEffect(showTopSnackBar) { | |
if (showTopSnackBar) { | |
timer.value = object : CountDownTimer(3000, 1000) { | |
override fun onTick(millisUntilFinished: Long) {} | |
override fun onFinish() { | |
coroutineScope.launch { | |
showTopSnackBar = false | |
} | |
} | |
}.start() | |
} else { | |
timer.value?.cancel() | |
} | |
} | |
var email by remember { | |
mutableStateOf(state.ChangeEmailState!!.email) | |
} | |
val focusRequester = remember { | |
FocusRequester() | |
} | |
var revisionEmailButtonColor by remember { | |
mutableStateOf( | |
Color(0xffBFBFBF) | |
) | |
} | |
var continueButtonColor by remember { | |
mutableStateOf( | |
Color(0xffBFBFBF) | |
) | |
} | |
revisionEmailButtonColor = if (state.verificationState!!.permitEmailRevision) { | |
Color(0xff30D158) | |
} else { | |
Color(0xffBFBFBF) | |
} | |
continueButtonColor = if (state.verificationState!!.isOtpCorrect) { | |
Color(0xff30D158) | |
} else { | |
Color(0xffBFBFBF) | |
} | |
Box( | |
modifier = Modifier.fillMaxSize() | |
) { | |
Column( | |
modifier = Modifier.padding(0.dp, 0.dp), | |
horizontalAlignment = Alignment.CenterHorizontally | |
) { | |
Box( | |
modifier = Modifier | |
.background(color = Color.White) | |
.padding(50.dp, top = 15.dp + padding.calculateTopPadding(), bottom = 15.dp) | |
.fillMaxWidth() | |
) { | |
Text( | |
"Email Verification", style = TextStyle( | |
textAlign = TextAlign.Left, | |
color = Color(0xff1C1C1E), | |
fontSize = 20.sp, | |
fontFamily = FontFamily(Font(R.font.sf_pro)), | |
fontWeight = FontWeight.Bold | |
) | |
) | |
} | |
Card( | |
modifier = Modifier | |
.fillMaxWidth() | |
.padding(30.dp, 20.dp) | |
.height(50.dp), | |
colors = CardDefaults.cardColors( | |
containerColor = Color.White, | |
disabledContainerColor = Color.White, | |
contentColor = Color(0xff1C1C1E), | |
disabledContentColor = Color(0xff1C1C1E) | |
) | |
) { | |
Box(modifier = Modifier.fillMaxSize()) { | |
BasicTextField( | |
modifier = Modifier | |
.fillMaxWidth() | |
.align(Alignment.Center) | |
.padding(15.dp, 0.dp), | |
textStyle = TextStyle(fontSize = 17.sp, textAlign = TextAlign.Left), | |
value = email, | |
keyboardOptions = KeyboardOptions( | |
keyboardType = KeyboardType.Email | |
), | |
onValueChange = { value: String -> | |
email = value | |
}) | |
} | |
} | |
Column( | |
modifier = Modifier | |
.fillMaxWidth() | |
.padding(30.dp, 0.dp), | |
horizontalAlignment = Alignment.CenterHorizontally, | |
verticalArrangement = Arrangement.spacedBy(15.dp, Alignment.CenterVertically), | |
) { | |
Card( | |
modifier = Modifier | |
.clickable { | |
Timber.d("Verification: OTP is incorrect") | |
viewModel.sendIntent( | |
AuthIntent.ChangeEmailIntent.UpdateEmail( | |
) | |
) | |
} | |
.fillMaxWidth() | |
.padding(0.dp, 0.dp) | |
.height(50.dp), | |
colors = CardDefaults.cardColors( | |
containerColor = Color(0xff30D158), | |
disabledContainerColor = Color(0xff30D158), | |
contentColor = Color.White, | |
disabledContentColor = Color.White | |
), | |
shape = RoundedCornerShape(4.dp), | |
) { | |
Box(modifier = Modifier.fillMaxSize()) { | |
Text( | |
modifier = Modifier.align(Alignment.Center), style = TextStyle( | |
fontSize = 17.sp, | |
fontWeight = FontWeight.Bold, | |
textAlign = TextAlign.Center, | |
), text = "Update" | |
) | |
} | |
// show forms error | |
}/* | |
Card( | |
modifier = Modifier | |
.clickable { | |
*//* | |
viewModel.sendIntent( | |
AuthIntent.OnNavigation( | |
source = Screen.VerificationScreen.route, | |
destination = Screen.LoginScreen.route | |
) | |
) | |
*//* | |
viewModel.sendIntent( | |
AuthIntent.VerificationChangeEmailIntent.ResendCode | |
) | |
} | |
.fillMaxWidth() | |
.padding(0.dp, 0.dp) | |
.height(50.dp), | |
colors = CardDefaults.cardColors( | |
containerColor = Color.White, | |
disabledContainerColor = Color.White, | |
contentColor = Color.White, | |
disabledContentColor = Color.White | |
), | |
shape = RoundedCornerShape(4.dp), | |
) { | |
Box(modifier = Modifier.fillMaxSize()) { | |
Text( | |
modifier = Modifier.align(Alignment.Center), style = TextStyle( | |
fontSize = 17.sp, | |
color = Color(0xff007AFF), | |
fontWeight = FontWeight.Bold, | |
textAlign = TextAlign.Center, | |
), text = "Resend Link" | |
) | |
} | |
// show forms error | |
} | |
*/ | |
Card( | |
modifier = Modifier | |
.clickable { | |
Timber.d("Verification: OTP is incorrect") | |
viewModel.sendIntent( | |
AuthIntent.ChangeEmailIntent.BackToLogin | |
) | |
} | |
.fillMaxWidth() | |
.padding(0.dp, 0.dp) | |
.height(50.dp), | |
colors = CardDefaults.cardColors( | |
containerColor = Color.White, | |
disabledContainerColor = Color.White, | |
contentColor = Color.White, | |
disabledContentColor = Color.White | |
), | |
shape = RoundedCornerShape(4.dp), | |
) { | |
Box(modifier = Modifier.fillMaxSize()) { | |
Text( | |
modifier = Modifier.align(Alignment.Center), style = TextStyle( | |
fontSize = 17.sp, | |
color = Color(0xff007AFF), | |
fontWeight = FontWeight.Bold, | |
textAlign = TextAlign.Center, | |
), text = "Back To Login" | |
) | |
} | |
// show forms error | |
} | |
} | |
} | |
} | |
} | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\ui\screens\ForgotPasswordScreen.kt | |
```kt | |
package com.divadventure.ui.screens | |
import android.os.CountDownTimer | |
import androidx.compose.foundation.background | |
import androidx.compose.foundation.clickable | |
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.fillMaxSize | |
import androidx.compose.foundation.layout.fillMaxWidth | |
import androidx.compose.foundation.layout.height | |
import androidx.compose.foundation.layout.padding | |
import androidx.compose.foundation.layout.width | |
import androidx.compose.foundation.shape.RoundedCornerShape | |
import androidx.compose.material3.Card | |
import androidx.compose.material3.CardDefaults | |
import androidx.compose.material3.Scaffold | |
import androidx.compose.material3.Text | |
import androidx.compose.runtime.Composable | |
import androidx.compose.runtime.LaunchedEffect | |
import androidx.compose.runtime.collectAsState | |
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.Alignment | |
import androidx.compose.ui.Modifier | |
import androidx.compose.ui.focus.FocusRequester | |
import androidx.compose.ui.graphics.Color | |
import androidx.compose.ui.text.TextStyle | |
import androidx.compose.ui.text.buildAnnotatedString | |
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.style.TextAlign | |
import androidx.compose.ui.text.withStyle | |
import androidx.compose.ui.unit.dp | |
import androidx.compose.ui.unit.sp | |
import com.composeuisuite.ohteepee.OhTeePeeDefaults | |
import com.composeuisuite.ohteepee.OhTeePeeInput | |
import com.divadventure.R | |
import com.divadventure.ui.TopSnackBar | |
import com.divadventure.ui.theme.SystemUIManager | |
import com.divadventure.viewmodel.AuthIntent | |
import com.divadventure.viewmodel.AuthViewModel | |
import com.divadventure.data.navigation.NavigationViewModel | |
import com.divadventure.viewmodel.AuthUiEvent | |
import kotlinx.coroutines.delay | |
import kotlinx.coroutines.launch | |
import timber.log.Timber | |
@Composable | |
fun ForgotPasswordScreen( | |
viewModel: AuthViewModel, | |
navigationViewModel: NavigationViewModel, | |
) { | |
val state by viewModel.state.collectAsState() | |
SystemUIManager( | |
isDarkThemeForBottom = false, | |
isDarkThemeForStatusBar = false, | |
Color.Transparent, | |
Color.Transparent, | |
hideNavigationBar = !state.navigationBarVisibility, | |
hideStatusBar = !state.statusBarVisibility, | |
onSystemBarsVisibilityChange = { statusbar, navigationBar -> | |
viewModel.sendIntent( | |
AuthIntent.MutualIntent.ChangeInsetsVisibility( | |
statusbar, navigationBar | |
) | |
) | |
}) | |
Scaffold( | |
containerColor = Color(0xffefeff4) | |
) { paddingValues: PaddingValues -> | |
var otpString by remember { mutableStateOf("") } | |
val email = remember { | |
mutableStateOf("") | |
} | |
remember { | |
FocusRequester() | |
} | |
var continueButtonColor by remember { | |
mutableStateOf( | |
Color(0xffBFBFBF) | |
) | |
} | |
continueButtonColor = if (state.forgotPasswordState!!.timeEnd) { | |
Color(0xff30D158) | |
} else { | |
Color(0xffBFBFBF) | |
} | |
var resendButtonColor by remember { | |
mutableStateOf(Color(0xff007AFF)) | |
} | |
resendButtonColor = if (state.forgotPasswordState!!.timeEnd == true) { | |
Color(0xff007AFF) | |
} else { | |
Color(0xffBFBFBF) | |
} | |
// this config will be used for each cell | |
val defaultCellConfig = OhTeePeeDefaults.cellConfiguration( | |
borderColor = Color.LightGray, | |
borderWidth = 0.dp, | |
shape = RoundedCornerShape(8.dp), | |
textStyle = TextStyle( | |
color = Color.Black | |
) | |
) | |
val filledCellConfig = OhTeePeeDefaults.cellConfiguration( | |
borderColor = Color(0xff007AFF), | |
borderWidth = 1.dp, | |
shape = RoundedCornerShape(8.dp), | |
textStyle = TextStyle( | |
color = Color.Black | |
) | |
) | |
Box( | |
modifier = Modifier.fillMaxSize() | |
) { | |
var showTopSnackBar by remember { mutableStateOf(false) } | |
var topSnackBarMessage by remember { mutableStateOf("") } | |
var topSnackBarTitle by remember { mutableStateOf("") } | |
val timer = remember { mutableStateOf<CountDownTimer?>(null) } | |
val coroutineScope = rememberCoroutineScope() | |
var showDialog by remember { mutableStateOf(false) } | |
// Handle UiEvents and SnackBar | |
LaunchedEffect(key1 = true) { | |
viewModel.uiEvent.collect { event -> | |
when (event) { | |
AuthUiEvent.AnimateItem -> {} | |
AuthUiEvent.ShowDialog -> { | |
showDialog = true | |
} | |
is AuthUiEvent.ShowSnackbar -> { | |
topSnackBarMessage = event.message | |
topSnackBarTitle = event.title | |
showTopSnackBar = true | |
} | |
is AuthUiEvent.ExecuteNavigation -> { | |
navigationViewModel.navigate( | |
event.navigationEvent | |
) | |
} | |
} | |
} | |
} | |
// Handle timer and auto-dismiss | |
LaunchedEffect(showTopSnackBar) { | |
if (showTopSnackBar) { | |
timer.value = object : CountDownTimer(3000, 1000) { | |
override fun onTick(millisUntilFinished: Long) {} | |
override fun onFinish() { | |
coroutineScope.launch { | |
showTopSnackBar = false | |
} | |
} | |
}.start() | |
} else { | |
timer.value?.cancel() | |
} | |
} | |
TopSnackBar( | |
paddingTop = paddingValues.calculateTopPadding(), | |
title = topSnackBarTitle, | |
message = topSnackBarMessage, | |
show = showTopSnackBar, | |
onDismiss = { showTopSnackBar = false }) | |
Column( | |
modifier = Modifier, horizontalAlignment = Alignment.CenterHorizontally | |
) { | |
Box( | |
modifier = Modifier | |
.background(color = Color.White) | |
.padding( | |
50.dp, | |
bottom = 15.dp, | |
top = 15.dp + paddingValues.calculateTopPadding() | |
) | |
.fillMaxWidth() | |
) { | |
Text( | |
"Verification Code", style = TextStyle( | |
textAlign = TextAlign.Left, | |
color = Color(0xff1C1C1E), | |
fontSize = 20.sp, | |
fontFamily = FontFamily(Font(R.font.sf_pro)), | |
fontWeight = FontWeight.Bold | |
) | |
) | |
} | |
Box( | |
modifier = Modifier | |
.padding(30.dp) | |
.fillMaxWidth() | |
) { | |
Column(modifier = Modifier.fillMaxWidth()) { | |
Text( | |
modifier = Modifier | |
.padding(0.dp, 5.dp) | |
.fillMaxWidth(), | |
text = "A verification code has been sent to", | |
style = TextStyle( | |
color = Color.Black, | |
textAlign = TextAlign.Center, | |
fontFamily = FontFamily(Font(R.font.sf_pro)) | |
) | |
) | |
Text( | |
modifier = Modifier.fillMaxWidth(), | |
text = state.forgotPasswordState!!.email, | |
style = TextStyle( | |
color = Color.Black, | |
fontFamily = FontFamily(Font(R.font.sf_pro)), | |
fontSize = 16.sp, | |
textAlign = TextAlign.Center, | |
fontWeight = FontWeight.Bold | |
) | |
) | |
} | |
} | |
var otpTimeLeft by remember { mutableStateOf(120) } // Initialize timeLeft to 120 seconds | |
LaunchedEffect(key1 = state.forgotPasswordState!!.resetTimer) { | |
if (state.forgotPasswordState!!.resetTimer) { | |
otpTimeLeft = 120 // Reset the timer | |
while (otpTimeLeft > 0) { | |
delay(1000L) // Wait for 1 second | |
otpTimeLeft-- // Decrease timeLeft by 1 | |
// Send the updated timeLeft to the ViewModel | |
viewModel.sendIntent( | |
AuthIntent.ForgotPasswordIntent.CheckRemainTime( | |
otpTimeLeft | |
) | |
) | |
} | |
viewModel.sendIntent( | |
AuthIntent.ForgotPasswordIntent.CheckRemainTime( | |
otpTimeLeft | |
) | |
) | |
// Handle the case when timeLeft reaches 0 (e.g., disable resend button) | |
Timber.d("Timer Finished") | |
viewModel.state.value.forgotPasswordState!!.resetTimer = | |
false // Set resetTimer to false | |
} | |
} | |
Text( | |
modifier = Modifier | |
.padding(30.dp, 0.dp) | |
.fillMaxWidth(), | |
text = buildAnnotatedString { | |
append( | |
if (state.forgotPasswordState!!.timeEnd == false) "Please check your inbox and enter the verification code below to verify your email address. The code will expire in " | |
else "The time has been finished" | |
) | |
withStyle(style = TextStyle(fontWeight = FontWeight.Bold).toSpanStyle()) { | |
append( | |
if (state.forgotPasswordState!!.timeEnd == false) " $otpTimeLeft seconds" | |
else "" | |
) | |
} | |
}, | |
style = TextStyle( | |
color = Color.Black, | |
fontFamily = FontFamily(Font(R.font.sf_pro)) | |
), | |
textAlign = TextAlign.Center, | |
) | |
Box(modifier = Modifier.fillMaxWidth()) { | |
OhTeePeeInput( | |
modifier = Modifier | |
.align(Alignment.Center) | |
.padding(0.dp, 20.dp), | |
value = otpString, | |
onValueChange = { newValue: String, isValid: Boolean -> | |
otpString = newValue | |
viewModel.sendIntent( | |
AuthIntent.ForgotPasswordIntent.OnOtpChanged(otpString.trim()) | |
) | |
Timber.d("OTP: $newValue") | |
}, | |
autoFocusByDefault = true, | |
horizontalArrangement = Arrangement.spacedBy(1.dp), | |
configurations = OhTeePeeDefaults.inputConfiguration( | |
cellsCount = 6, | |
cellModifier = Modifier | |
.width(46.dp) | |
.height(54.dp), | |
activeCellConfig = filledCellConfig, | |
emptyCellConfig = defaultCellConfig, | |
filledCellConfig = filledCellConfig, | |
), | |
) | |
} | |
Column( | |
modifier = Modifier | |
.fillMaxWidth() | |
.padding(30.dp, 0.dp), | |
horizontalAlignment = Alignment.CenterHorizontally, | |
verticalArrangement = Arrangement.spacedBy(0.dp, Alignment.CenterVertically), | |
) { | |
Card( | |
modifier = Modifier | |
.clickable { | |
/* | |
viewModel.sendIntent( | |
AuthIntent.VerificationIntent.OnOtpVerifyPressed( | |
otp = otpString | |
) | |
) | |
*/ | |
} | |
.fillMaxWidth() | |
.padding(0.dp, 0.dp) | |
.height(50.dp), | |
colors = CardDefaults.cardColors( | |
containerColor = Color(0xff30D158), | |
disabledContainerColor = Color(0xff30D158), | |
contentColor = Color.White, | |
disabledContentColor = Color.White | |
), | |
shape = RoundedCornerShape(4.dp), | |
) { | |
Box(modifier = Modifier.fillMaxSize()) { | |
Text( | |
modifier = Modifier.align(Alignment.Center), style = TextStyle( | |
fontSize = 17.sp, | |
fontWeight = FontWeight.Bold, | |
textAlign = TextAlign.Center, | |
), text = "Verify" | |
) | |
} | |
// show forms error | |
} | |
Row( | |
modifier = Modifier | |
.fillMaxWidth() | |
.padding(0.dp, 10.dp) | |
) { | |
Text( | |
modifier = | |
if (otpTimeLeft == 0) | |
Modifier.clickable { | |
viewModel.sendIntent( | |
AuthIntent.ForgotPasswordIntent.ResendCode | |
) | |
} | |
else Modifier, | |
text = "Resend Code", | |
fontSize = 17.sp, | |
fontFamily = FontFamily(Font(R.font.sf_pro)), | |
style = TextStyle( | |
color = resendButtonColor | |
), | |
textAlign = TextAlign.Left | |
) | |
Spacer(modifier = Modifier.weight(1f, true)) | |
} | |
} | |
} | |
} | |
} | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\ui\screens\LandingScreen.kt | |
```kt | |
package com.divadventure.ui.screens | |
import android.os.CountDownTimer | |
import androidx.compose.foundation.Image | |
import androidx.compose.foundation.clickable | |
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.Spacer | |
import androidx.compose.foundation.layout.fillMaxSize | |
import androidx.compose.foundation.layout.fillMaxWidth | |
import androidx.compose.foundation.layout.padding | |
import androidx.compose.foundation.shape.CornerSize | |
import androidx.compose.foundation.shape.RoundedCornerShape | |
import androidx.compose.material3.Card | |
import androidx.compose.material3.CardColors | |
import androidx.compose.material3.Scaffold | |
import androidx.compose.material3.Text | |
import androidx.compose.runtime.Composable | |
import androidx.compose.runtime.LaunchedEffect | |
import androidx.compose.runtime.collectAsState | |
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.Alignment | |
import androidx.compose.ui.Modifier | |
import androidx.compose.ui.graphics.Color | |
import androidx.compose.ui.layout.ContentScale | |
import androidx.compose.ui.res.painterResource | |
import androidx.compose.ui.text.TextStyle | |
import androidx.compose.ui.text.font.FontWeight | |
import androidx.compose.ui.text.style.TextAlign | |
import androidx.compose.ui.unit.dp | |
import androidx.compose.ui.unit.sp | |
import coil.compose.rememberAsyncImagePainter | |
import com.divadventure.R | |
import com.divadventure.ui.theme.SystemUIManager | |
import com.divadventure.viewmodel.AuthIntent | |
import com.divadventure.viewmodel.AuthViewModel | |
import com.divadventure.data.navigation.NavigationViewModel | |
import com.divadventure.viewmodel.AuthUiEvent | |
import kotlin.random.Random | |
@Composable | |
fun LandingScreen( | |
viewModel: AuthViewModel, | |
navigationViewModel: NavigationViewModel, | |
padding: PaddingValues | |
) { | |
val state by viewModel.state.collectAsState() | |
SystemUIManager( | |
isDarkThemeForBottom = false, | |
isDarkThemeForStatusBar = false, | |
Color.Transparent, | |
Color.Transparent, | |
hideNavigationBar = !state.navigationBarVisibility, | |
hideStatusBar = !state.statusBarVisibility, | |
onSystemBarsVisibilityChange = { statusbar, navigationBar -> | |
/* | |
viewModel.sendIntent( | |
AuthIntent.MutualIntent.ChangeInsetsVisibility( | |
statusbar, navigationBar | |
) | |
) | |
*/ | |
}) | |
Scaffold( | |
topBar = { | |
// Add a top bar here if required in the future | |
} | |
) { paddingValues -> | |
var mainTextColor = remember { mutableStateOf(Color(0xff1C1C1E)) } | |
val random = Random.nextInt(1, 3) | |
val landing = when (random) { | |
1 -> { | |
mainTextColor.value = Color(0xff1C1C1E) | |
R.drawable.landing1 | |
} | |
else -> { | |
mainTextColor.value = Color.White | |
R.drawable.landing2 | |
} | |
} | |
Box(modifier = Modifier.fillMaxSize()) { | |
var showTopSnackBar by remember { mutableStateOf(false) } | |
var topSnackBarMessage by remember { mutableStateOf("") } | |
var topSnackBarTitle by remember { mutableStateOf("") } | |
val timer = remember { mutableStateOf<CountDownTimer?>(null) } | |
val coroutineScope = rememberCoroutineScope() | |
var showDialog by remember { mutableStateOf(false) } | |
// Handle UiEvents and SnackBar | |
LaunchedEffect(key1 = true) { | |
viewModel.uiEvent.collect { event -> | |
when (event) { | |
AuthUiEvent.AnimateItem -> {} | |
AuthUiEvent.ShowDialog -> { | |
showDialog = true | |
} | |
is AuthUiEvent.ShowSnackbar -> { | |
topSnackBarMessage = event.message | |
topSnackBarTitle = event.title | |
showTopSnackBar = true | |
} | |
is AuthUiEvent.ExecuteNavigation -> { | |
navigationViewModel.navigate( | |
event.navigationEvent | |
) | |
} | |
} | |
} | |
} | |
Image( | |
painter = rememberAsyncImagePainter(model = landing), | |
contentDescription = "Landing", | |
modifier = Modifier | |
.fillMaxSize(), | |
contentScale = ContentScale.FillBounds | |
) | |
Column( | |
modifier = Modifier | |
.fillMaxWidth(), | |
verticalArrangement = Arrangement.Center, | |
horizontalAlignment = Alignment.CenterHorizontally | |
) { | |
Spacer(modifier = Modifier.weight(0.2f)) | |
Text( | |
modifier = Modifier.weight(0.4f), | |
text = "DivAdventure", style = TextStyle( | |
fontWeight = FontWeight.Bold, | |
fontSize = 30.sp, | |
color = mainTextColor.value | |
) | |
) | |
} | |
Column( | |
modifier = Modifier | |
.fillMaxSize() | |
.align(Alignment.Center) | |
.padding(0.dp, 0.dp, 0.dp, 80.dp), | |
verticalArrangement = Arrangement.spacedBy( | |
10.dp, | |
Alignment.Bottom | |
) | |
) { | |
val coroutineScope = rememberCoroutineScope() | |
Card( | |
modifier = Modifier | |
.clickable { | |
viewModel.sendIntent( | |
AuthIntent.LandingIntent.gotoSignup | |
) | |
} | |
.fillMaxWidth() | |
.padding(15.dp, 0.dp), | |
colors = CardColors( | |
containerColor = Color(0xFF30D158), | |
contentColor = Color.White, | |
disabledContainerColor = Color(0xFF30D158), | |
disabledContentColor = Color.White | |
), shape = RoundedCornerShape(4.dp) | |
) { | |
Box( | |
modifier = Modifier.fillMaxWidth(), | |
contentAlignment = Alignment.Center | |
) { | |
Text( | |
text = "Sign Up", modifier = Modifier | |
.padding(16.dp), | |
style = TextStyle( | |
fontWeight = FontWeight.Bold, | |
fontSize = 17.sp | |
), | |
textAlign = TextAlign.Center | |
) | |
} | |
} | |
Card( | |
modifier = Modifier | |
.fillMaxWidth() | |
.padding(15.dp, 0.dp), | |
colors = CardColors( | |
containerColor = Color(0xFFf2f2f7), | |
contentColor = Color(0xff251514), | |
disabledContainerColor = Color(0xFFf2f2f7), | |
disabledContentColor = Color(0xff251514) | |
), | |
shape = RoundedCornerShape(4.dp).copy(all = CornerSize(4.dp)) | |
) { | |
Box( | |
modifier = Modifier | |
.clickable { | |
viewModel.sendIntent( | |
AuthIntent.LandingIntent.gotoLogin | |
) | |
} | |
.fillMaxWidth(), contentAlignment = Alignment.Center | |
) { | |
Text( | |
text = "Login", modifier = Modifier.padding(16.dp), style = TextStyle( | |
fontWeight = FontWeight.Bold, | |
fontSize = 17.sp | |
), | |
textAlign = TextAlign.Center | |
) | |
} | |
} | |
} | |
} | |
} | |
} // Closing Scaffold | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\ui\screens\Login.kt | |
```kt | |
package com.divadventure.ui.screens | |
import android.os.CountDownTimer | |
import androidx.compose.foundation.BorderStroke | |
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.Row | |
import androidx.compose.foundation.layout.fillMaxSize | |
import androidx.compose.foundation.layout.fillMaxWidth | |
import androidx.compose.foundation.layout.height | |
import androidx.compose.foundation.layout.heightIn | |
import androidx.compose.foundation.layout.padding | |
import androidx.compose.foundation.layout.width | |
import androidx.compose.foundation.shape.RoundedCornerShape | |
import androidx.compose.material3.Card | |
import androidx.compose.material3.CardDefaults | |
import androidx.compose.material3.ExperimentalMaterial3Api | |
import androidx.compose.material3.HorizontalDivider | |
import androidx.compose.material3.OutlinedTextFieldDefaults | |
import androidx.compose.material3.Scaffold | |
import androidx.compose.material3.SnackbarDuration | |
import androidx.compose.material3.SnackbarHostState | |
import androidx.compose.material3.Text | |
import androidx.compose.material3.TextField | |
import androidx.compose.material3.VerticalDivider | |
import androidx.compose.runtime.Composable | |
import androidx.compose.runtime.LaunchedEffect | |
import androidx.compose.runtime.collectAsState | |
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.Alignment | |
import androidx.compose.ui.Modifier | |
import androidx.compose.ui.graphics.Color | |
import androidx.compose.ui.graphics.vector.ImageVector | |
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.style.TextAlign | |
import androidx.compose.ui.text.style.TextDecoration | |
import androidx.compose.ui.unit.dp | |
import androidx.compose.ui.unit.sp | |
import androidx.compose.ui.window.Dialog | |
import com.divadventure.R | |
import com.divadventure.ui.AuthTextField | |
import com.divadventure.ui.CARD_HEIGHT | |
import com.divadventure.ui.TopSnackBar | |
import com.divadventure.ui.theme.SystemUIManager | |
import com.divadventure.viewmodel.AuthIntent | |
import com.divadventure.viewmodel.AuthViewModel | |
import com.divadventure.data.navigation.NavigationViewModel | |
import com.divadventure.viewmodel.AuthUiEvent | |
import kotlinx.coroutines.launch | |
@OptIn(ExperimentalMaterial3Api::class) | |
@Composable | |
fun LoginScreen( | |
viewModel: AuthViewModel, | |
navigationViewModel: NavigationViewModel, | |
) { | |
val state by viewModel.state.collectAsState() | |
SystemUIManager( | |
isDarkThemeForBottom = false, | |
isDarkThemeForStatusBar = false, | |
Color.Transparent, | |
Color.Transparent, | |
hideNavigationBar = !state.navigationBarVisibility, | |
hideStatusBar = !state.statusBarVisibility, | |
onSystemBarsVisibilityChange = { statusbar, navigationBar -> | |
/* | |
viewModel.sendIntent( | |
AuthIntent.MutualIntent.ChangeInsetsVisibility( | |
statusbar, navigationBar | |
) | |
) | |
*/ | |
}) | |
val snackBarHost = remember { SnackbarHostState() } | |
var showTopSnackBar by remember { mutableStateOf(false) } | |
var topSnackBarMessage by remember { mutableStateOf("") } | |
var topSnackBarTitle by remember { mutableStateOf("") } | |
val timer = remember { mutableStateOf<CountDownTimer?>(null) } | |
val coroutineScope = rememberCoroutineScope() | |
var showDialog by remember { mutableStateOf(false) } | |
// Handle UiEvents and SnackBar | |
LaunchedEffect(key1 = true) { | |
viewModel.uiEvent.collect { event -> | |
when (event) { | |
AuthUiEvent.AnimateItem -> {} | |
AuthUiEvent.ShowDialog -> { | |
showDialog = true | |
} | |
is AuthUiEvent.ShowSnackbar -> { | |
topSnackBarMessage = event.message | |
topSnackBarTitle = event.title | |
showTopSnackBar = true | |
} | |
is AuthUiEvent.ExecuteNavigation -> { | |
navigationViewModel.navigate( | |
event.navigationEvent | |
) | |
} | |
} | |
} | |
} | |
// Handle timer and auto-dismiss | |
LaunchedEffect(showTopSnackBar) { | |
if (showTopSnackBar) { | |
timer.value = object : CountDownTimer(3000, 1000) { | |
override fun onTick(millisUntilFinished: Long) {} | |
override fun onFinish() { | |
coroutineScope.launch { | |
showTopSnackBar = false | |
} | |
} | |
}.start() | |
} else { | |
timer.value?.cancel() | |
} | |
} | |
Scaffold( | |
// modifier = Modifier.padding(padding), | |
containerColor = Color.White | |
) { padding -> | |
var forgetPasswordEmailButtonColor by remember { mutableStateOf(Color(0xffBFBFBF)) } | |
forgetPasswordEmailButtonColor = when (state.loginState!!.forgetPasswordEmailCorrect) { | |
true -> { | |
Color(0xff007AFF) | |
} | |
false -> { | |
Color(0xffBFBFBF) | |
} | |
} | |
var loginButtonColor by remember { | |
mutableStateOf( | |
Color(0xff30D158) | |
) | |
} | |
when (state.loginState!!.loginClickable) { | |
true -> { | |
loginButtonColor = Color(0xff30D158) | |
} | |
false -> { | |
loginButtonColor = Color(0xffBFBFBF) | |
} | |
} | |
var emailOrUsername = remember { | |
mutableStateOf("") | |
} | |
var password = remember { | |
mutableStateOf("") | |
} | |
var showDialog by remember { | |
mutableStateOf(false) | |
} | |
var forgotEmail by remember { | |
mutableStateOf("") | |
} | |
LaunchedEffect(key1 = true) { | |
viewModel.uiEvent.collect { event -> | |
when (event) { | |
AuthUiEvent.AnimateItem -> {} | |
is AuthUiEvent.ExecuteNavigation -> {} | |
AuthUiEvent.ShowDialog -> { | |
showDialog = true | |
} | |
is AuthUiEvent.ShowSnackbar -> { | |
launch { | |
snackBarHost.showSnackbar( | |
message = event.message, duration = SnackbarDuration.Long | |
) | |
} | |
} | |
} | |
} | |
} | |
Box( | |
modifier = Modifier.fillMaxSize() | |
) { | |
if (showDialog) { | |
Dialog( | |
onDismissRequest = { showDialog = false }, | |
) { | |
Card( | |
modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors( | |
containerColor = Color(0xFFf2f2f2), | |
disabledContainerColor = Color(0xFFf2f2f2), | |
contentColor = Color.White, | |
disabledContentColor = Color.White | |
) | |
) { | |
Column { | |
Column( | |
modifier = Modifier.padding(10.dp, 10.dp), | |
horizontalAlignment = Alignment.CenterHorizontally | |
) { | |
Text( | |
text = "Forgot Password", | |
modifier = Modifier | |
.fillMaxWidth() | |
.padding(0.dp, 10.dp), | |
style = TextStyle( | |
fontWeight = FontWeight.SemiBold, | |
fontSize = 22.sp, | |
textAlign = TextAlign.Center, | |
color = Color.Black | |
) | |
) | |
Text( | |
modifier = Modifier | |
.padding(0.dp, 10.dp) | |
.fillMaxWidth(), | |
text = "To initiate the password reset process, " + "please provide your email address below:", | |
style = TextStyle(color = Color.Black, fontSize = 16.sp), | |
textAlign = TextAlign.Center | |
) | |
Card( | |
modifier = Modifier | |
.padding(10.dp, 10.dp) | |
.heightIn(20.dp, 50.dp), | |
border = BorderStroke( | |
1.dp, Color(0x40000000) | |
), | |
shape = RoundedCornerShape(6.dp), | |
colors = CardDefaults.cardColors( | |
containerColor = Color.White | |
) | |
) { | |
TextField( | |
modifier = Modifier | |
.fillMaxSize(), | |
value = forgotEmail, | |
onValueChange = { | |
forgotEmail = it | |
viewModel.sendIntent( | |
AuthIntent.LoginIntent.OnForgotPasswordChanged(it) | |
) | |
}, | |
textStyle = TextStyle( | |
color = Color.Black, fontSize = 16.sp | |
), | |
colors = OutlinedTextFieldDefaults.colors( | |
errorBorderColor = Color.Transparent, | |
focusedBorderColor = Color.Transparent, | |
unfocusedBorderColor = Color.Transparent, | |
cursorColor = Color.Black | |
), | |
shape = RoundedCornerShape(1.dp), | |
interactionSource = remember { MutableInteractionSource() }, | |
singleLine = true | |
) | |
} | |
} | |
} | |
HorizontalDivider( | |
modifier = Modifier | |
.fillMaxWidth() | |
.background(Color.Transparent) | |
) | |
Row( | |
modifier = Modifier.height(50.dp), | |
verticalAlignment = Alignment.CenterVertically | |
) { | |
Box( | |
modifier = Modifier | |
.clickable { | |
showDialog = false | |
} | |
.weight(0.5f, true)) { | |
Text( | |
modifier = Modifier.fillMaxWidth(), | |
text = "Cancel", | |
style = TextStyle( | |
fontSize = 17.sp, | |
fontWeight = FontWeight.SemiBold, | |
color = Color(0xff007AFF), | |
textAlign = TextAlign.Center | |
) | |
) | |
} | |
VerticalDivider( | |
modifier = Modifier.width(1.dp) | |
) | |
Box( | |
modifier = Modifier | |
.clickable { | |
viewModel.sendIntent( | |
AuthIntent.LoginIntent.ForgotPassword( | |
forgotEmail | |
) | |
) | |
showDialog = false | |
} | |
.weight(0.5f, true)) { | |
Text( | |
modifier = Modifier.fillMaxWidth(), | |
text = "Send Code", | |
style = TextStyle( | |
color = forgetPasswordEmailButtonColor, | |
textAlign = TextAlign.Center, | |
fontWeight = FontWeight.SemiBold | |
) | |
) | |
} | |
} | |
} | |
} | |
} | |
} | |
Column( | |
modifier = Modifier | |
.fillMaxSize() | |
.padding(0.dp, 0.dp), | |
horizontalAlignment = Alignment.CenterHorizontally | |
) { | |
Box(modifier = Modifier.weight(0.3f, true)) { | |
TopSnackBar( | |
paddingTop = padding.calculateTopPadding(), | |
title = topSnackBarTitle, | |
message = topSnackBarMessage, | |
show = showTopSnackBar, | |
onDismiss = { showTopSnackBar = false }) | |
Text( | |
modifier = Modifier | |
.align( | |
Alignment.Center | |
) | |
.padding(0.dp, 0.dp), text = "DivAdventure", style = TextStyle( | |
fontFamily = FontFamily(Font(R.font.sf_pro)), | |
textAlign = TextAlign.Center, | |
fontWeight = FontWeight.Bold, | |
fontSize = 32.sp, | |
color = Color(0xff30D158), | |
) | |
) | |
} | |
Column(modifier = Modifier.weight(0.333f, true)) { | |
AuthTextField( | |
modifier = Modifier, hint = "Email", text = emailOrUsername, onValueChange = { | |
viewModel.sendIntent( | |
AuthIntent.LoginIntent.OnEmailChanged( | |
email = emailOrUsername.value | |
) | |
) | |
}, explain = "Your Email Address" | |
) | |
AuthTextField( | |
modifier = Modifier.padding(0.dp, 40.dp, 0.dp, 0.dp), | |
hint = "Password", | |
text = password, | |
isPassword = true, | |
onValueChange = { | |
viewModel.sendIntent( | |
AuthIntent.LoginIntent.OnPasswordChanged( | |
password = password.value | |
) | |
) | |
}, | |
explain = "Your Password" | |
) | |
Box( | |
modifier = Modifier.fillMaxWidth() | |
) { | |
Text( | |
text = "Forgot Password?", style = TextStyle( | |
textDecoration = TextDecoration.Underline, | |
fontWeight = FontWeight.SemiBold, | |
color = Color.Black, | |
fontSize = 12.sp, | |
textAlign = TextAlign.Center | |
), modifier = Modifier | |
.padding(0.dp, 20.dp) | |
.align( | |
Alignment.Center | |
) | |
.clickable { | |
// viewModel.sendIntent(AuthIntent.LoginIntent.ForgotPassword) | |
showDialog = true | |
}) | |
} | |
} | |
Column( | |
modifier = Modifier | |
.fillMaxWidth() | |
.weight(0.333f, true) | |
.padding(15.dp, 0.dp), | |
horizontalAlignment = Alignment.CenterHorizontally, | |
verticalArrangement = Arrangement.spacedBy(15.dp, Alignment.Top) | |
) { | |
Card( | |
modifier = Modifier | |
.clickable { | |
viewModel.sendIntent( | |
AuthIntent.LoginIntent.Login( | |
emailOrUsername = emailOrUsername.value, | |
password = password.value | |
) | |
) | |
} | |
.fillMaxWidth() | |
.padding(0.dp, 0.dp) | |
.height(CARD_HEIGHT), | |
colors = CardDefaults.cardColors( | |
containerColor = loginButtonColor, | |
disabledContainerColor = loginButtonColor, | |
contentColor = Color.White, | |
disabledContentColor = Color.White | |
), | |
shape = RoundedCornerShape(4.dp), | |
) { | |
Box(modifier = Modifier.fillMaxSize()) { | |
Text( | |
modifier = Modifier.align(Alignment.Center), style = TextStyle( | |
fontSize = 17.sp, | |
fontWeight = FontWeight.Bold, | |
textAlign = TextAlign.Center, | |
), text = "Sign In" | |
) | |
} | |
// show forms error | |
Text(state.loginState!!.error, color = Color.Red) | |
} | |
Card( | |
modifier = Modifier | |
.clickable { | |
} | |
.fillMaxWidth() | |
.padding(0.dp, 0.dp) | |
.height(CARD_HEIGHT), | |
border = BorderStroke(1.dp, Color(0xffBFBFBF)), | |
colors = CardDefaults.cardColors( | |
containerColor = Color.White, | |
disabledContainerColor = Color.White, | |
contentColor = Color.White, | |
disabledContentColor = Color.White | |
), | |
shape = RoundedCornerShape(4.dp), | |
) { | |
Box(modifier = Modifier.fillMaxSize()) { | |
Row( | |
modifier = Modifier.align(Alignment.Center), | |
verticalAlignment = Alignment.CenterVertically | |
) { | |
Image( | |
modifier = Modifier.padding(0.dp, 0.dp, 10.dp, 0.dp), | |
imageVector = ImageVector.vectorResource(R.drawable.ic_google), | |
contentDescription = "google", | |
) | |
Text( | |
modifier = Modifier.padding( | |
10.dp, 0.dp, 0.dp, 0.dp | |
), style = TextStyle( | |
fontSize = 17.sp, | |
fontWeight = FontWeight.Bold, | |
textAlign = TextAlign.Center, | |
color = Color.Black | |
), text = "Sign In with Google" | |
) | |
} | |
} | |
} | |
Card( | |
modifier = Modifier | |
.clickable {} | |
.fillMaxWidth() | |
.padding(0.dp, 0.dp) | |
.height(CARD_HEIGHT), | |
colors = CardDefaults.cardColors( | |
containerColor = Color(0xff2553B4), | |
disabledContainerColor = Color(0xff2553B4), | |
contentColor = Color.White, | |
disabledContentColor = Color.White | |
), | |
shape = RoundedCornerShape(4.dp), | |
) { | |
Box(modifier = Modifier.fillMaxSize()) { | |
Row( | |
modifier = Modifier.align(Alignment.Center), | |
verticalAlignment = Alignment.CenterVertically | |
) { | |
Image( | |
modifier = Modifier.padding(0.dp, 0.dp, 10.dp, 0.dp), | |
imageVector = ImageVector.vectorResource(R.drawable.ic_facebook), | |
contentDescription = "facebook", | |
) | |
Text( | |
modifier = Modifier.padding( | |
10.dp, 0.dp, 0.dp, 0.dp | |
), style = TextStyle( | |
fontSize = 17.sp, | |
fontWeight = FontWeight.Bold, | |
textAlign = TextAlign.Center, | |
color = Color.White | |
), text = "Sign In with Facebook" | |
) | |
} | |
} | |
} | |
} | |
} | |
} | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\ui\screens\main\add\Add.kt | |
```kt | |
package com.divadventure.ui.screens.main.add | |
import androidx.compose.foundation.Image | |
import androidx.compose.foundation.background | |
import androidx.compose.foundation.gestures.awaitEachGesture | |
import androidx.compose.foundation.gestures.awaitFirstDown | |
import androidx.compose.foundation.layout.Box | |
import androidx.compose.foundation.layout.Column | |
import androidx.compose.foundation.layout.PaddingValues | |
import androidx.compose.foundation.layout.fillMaxSize | |
import androidx.compose.foundation.layout.fillMaxWidth | |
import androidx.compose.foundation.layout.padding | |
import androidx.compose.foundation.rememberScrollState | |
import androidx.compose.foundation.verticalScroll | |
import androidx.compose.material3.CircularProgressIndicator | |
import androidx.compose.material3.HorizontalDivider | |
import androidx.compose.material3.Icon | |
import androidx.compose.material3.SnackbarDuration | |
import androidx.compose.material3.SnackbarHostState | |
import androidx.compose.material3.Switch | |
import androidx.compose.material3.SwitchDefaults | |
import androidx.compose.runtime.Composable | |
import androidx.compose.runtime.LaunchedEffect | |
import androidx.compose.runtime.collectAsState | |
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.geometry.Offset | |
import androidx.compose.ui.graphics.Color | |
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection | |
import androidx.compose.ui.input.nestedscroll.NestedScrollSource | |
import androidx.compose.ui.input.nestedscroll.nestedScroll | |
import androidx.compose.ui.input.pointer.pointerInput | |
import androidx.compose.ui.res.painterResource | |
import androidx.compose.ui.unit.dp | |
import com.divadventure.R | |
import com.divadventure.data.navigation.NavigationViewModel | |
import com.divadventure.ui.BackCompose | |
import com.divadventure.ui.ChangerButton | |
import com.divadventure.ui.ItemTextClickIcon | |
import com.divadventure.ui.MandatoryInterestsComposable | |
import com.divadventure.ui.PersonalInfoTextField | |
import com.divadventure.ui.SelectDate | |
import com.divadventure.ui.SelectionList | |
import com.divadventure.ui.SimpleTextField | |
import com.divadventure.ui.TitleCompose | |
import com.divadventure.ui.WhiteRoundedCornerFrame | |
import com.divadventure.ui.screens.main.home.notifications.search.filter.GoogleMapWithLocationSearch | |
import com.divadventure.ui.screens.main.home.notifications.search.filter.calendarDayToString | |
import com.divadventure.viewmodel.AdventureUIEvent.AnimateItem | |
import com.divadventure.viewmodel.AdventureUIEvent.NavigateToNextScreen | |
import com.divadventure.viewmodel.AdventureUIEvent.ShowEndDateDialog | |
import com.divadventure.viewmodel.AdventureUIEvent.ShowSnackbar | |
import com.divadventure.viewmodel.AdventureUIEvent.ShowStartDateDialog | |
import com.divadventure.viewmodel.AdventuresIntent | |
import com.divadventure.viewmodel.MainUiEvent | |
import com.divadventure.viewmodel.MainViewModel | |
import com.divadventure.viewmodel.ManageAdventureViewModel | |
import kotlinx.coroutines.launch | |
import java.time.LocalDateTime | |
import java.time.format.DateTimeFormatter | |
import java.util.Locale | |
@Composable | |
fun AddOrEditAdventure( | |
paddingValues: PaddingValues, | |
mainViewModel: MainViewModel, | |
manageAdventureViewModel: ManageAdventureViewModel, | |
navigationViewModel: NavigationViewModel, | |
) { | |
val manageAdventureState = manageAdventureViewModel.state.collectAsState().value | |
val onSelectPrivacy: (Int) -> Unit = { | |
manageAdventureViewModel.sendIntent(AdventuresIntent.ApplyPrivacyType(it)) | |
} | |
var adventureTitle by remember { mutableStateOf(manageAdventureState.adventureTitle) } | |
var aboutAdventure by remember { mutableStateOf(manageAdventureState.adventureDescription) } | |
var requestCondition by remember { mutableStateOf(manageAdventureState.requestCondition) } | |
var formIsReady = remember { mutableStateOf(false) } | |
var showStartDateDialog by remember { mutableStateOf(false) } | |
var showEndDateDialog by remember { mutableStateOf(false) } | |
var showDeadlineDateDialog by remember { mutableStateOf(false) } | |
var locationInputText by remember { mutableStateOf(manageAdventureState.locationAddress) } | |
val snackBarHost = remember { SnackbarHostState() } | |
LaunchedEffect( | |
adventureTitle, | |
aboutAdventure, | |
manageAdventureState.startDate, | |
manageAdventureState.endDate, | |
manageAdventureState.deadlineDate, | |
manageAdventureState.locationLat, | |
manageAdventureState.locationLng | |
) { | |
formIsReady.value = adventureTitle.isNotBlank() && | |
aboutAdventure.isNotBlank() && | |
manageAdventureState.startDate.isNotBlank() && | |
manageAdventureState.endDate.isNotBlank() && | |
manageAdventureState.deadlineDate.isNotBlank() && | |
manageAdventureState.locationLat != 0.0 && | |
manageAdventureState.locationLng != 0.0 | |
} | |
LaunchedEffect(key1 = true) { | |
manageAdventureViewModel.uiEvent.collect { event -> | |
when (event) { | |
is NavigateToNextScreen -> { | |
navigationViewModel.navigate(event.navigationEvent) | |
} | |
AnimateItem -> {} | |
ShowEndDateDialog -> { | |
showEndDateDialog = true | |
} | |
ShowStartDateDialog -> { | |
showStartDateDialog = true | |
} | |
is ShowSnackbar -> { | |
// Show snackbar when adventure is published or on error | |
launch { | |
snackBarHost.showSnackbar( | |
message = event.message, duration = SnackbarDuration.Long | |
) | |
} | |
} | |
} | |
} | |
} | |
LaunchedEffect(key1 = true) { | |
mainViewModel.uiEvent.collect { event -> | |
when { | |
event == MainUiEvent.AnimateItem -> { | |
} | |
event is MainUiEvent.NavigateToNextScreen -> { | |
navigationViewModel.navigate(event.navigationEvent) | |
} | |
event == MainUiEvent.ShowDialog -> { | |
} | |
event is MainUiEvent.ShowDim -> { | |
} | |
event is MainUiEvent.ShowSnackbar -> { | |
} | |
} | |
} | |
} | |
if (showStartDateDialog) { | |
SelectDate( | |
onDismissRequest = { showStartDateDialog = false }, | |
useClock = true, | |
onSelectDate = { date, clock, amPm -> | |
if (date != null && clock != null) { | |
val localDateTime = LocalDateTime.of(date.date, clock) | |
val formatter = DateTimeFormatter.ofPattern("MMM dd yyyy - h:mm a", Locale.ENGLISH) | |
val formattedDateTime = localDateTime.format(formatter) | |
manageAdventureViewModel.sendIntent( | |
AdventuresIntent.OnSetStartDate( | |
formattedDateTime, clock | |
) | |
) | |
} | |
showStartDateDialog = false | |
}) | |
} else if (showEndDateDialog) { | |
SelectDate( | |
onDismissRequest = { showEndDateDialog = false }, | |
useClock = true, | |
onSelectDate = { date, clock, amPm -> | |
if (date != null && clock != null) { | |
val localDateTime = LocalDateTime.of(date.date, clock) | |
val formatter = DateTimeFormatter.ofPattern("MMM dd yyyy - h:mm a", Locale.ENGLISH) | |
val formattedDateTime = localDateTime.format(formatter) | |
manageAdventureViewModel.sendIntent( | |
AdventuresIntent.OnSetEndDate( | |
formattedDateTime, clock | |
) | |
) | |
} | |
showEndDateDialog = false | |
}) | |
} else if (showDeadlineDateDialog) { | |
SelectDate( | |
onDismissRequest = { showDeadlineDateDialog = false }, onSelectDate = { date, clock, amPm -> | |
if (date != null) { | |
// Deadline doesn't use time/amPm, so keep original logic for formattedDate | |
val formattedDate = calendarDayToString(date) | |
manageAdventureViewModel.sendIntent( | |
AdventuresIntent.OnDeadlineChange( | |
formattedDate | |
) | |
) | |
} | |
showDeadlineDateDialog = false | |
} | |
) | |
} | |
Box( | |
modifier = Modifier | |
.fillMaxSize() | |
.background(Color.White) | |
.padding(paddingValues) | |
.background(Color(0xFFEFEFF4)) | |
.padding(bottom = 56.dp), | |
) { | |
val scrollState = rememberScrollState() | |
// Create a nested scroll connection that prioritizes map gestures | |
val nestedScrollConnection = remember { | |
object : NestedScrollConnection { | |
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { | |
// Let the parent Column handle vertical scrolling when not over the map | |
return Offset.Zero | |
} | |
} | |
} | |
fun Modifier.onPointerInteractionStartEnd( | |
onPointerStart: () -> Unit, | |
onPointerEnd: () -> Unit, | |
) = pointerInput(onPointerStart, onPointerEnd) { | |
awaitEachGesture { | |
awaitFirstDown(requireUnconsumed = false) | |
onPointerStart() | |
do { | |
val event = awaitPointerEvent() | |
} while (event.changes.any { it.pressed }) | |
onPointerEnd() | |
} | |
} | |
val isMapMoving = remember { mutableStateOf(false) } | |
val parentScrollConnection = remember { | |
object : NestedScrollConnection { | |
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { | |
// Customize the behavior if needed | |
return Offset.Zero | |
} | |
} | |
} | |
Column( | |
modifier = Modifier | |
.verticalScroll(scrollState, enabled = !isMapMoving.value) | |
.nestedScroll(parentScrollConnection) | |
) { | |
BackCompose( | |
"Create New Adventure" | |
) { | |
} | |
TitleCompose("Title", true) | |
WhiteRoundedCornerFrame( | |
) { | |
SimpleTextField( | |
adventureTitle, { | |
adventureTitle = it | |
manageAdventureViewModel.sendIntent(AdventuresIntent.OnTitleChange(it)) | |
}, descLines = 1, hint = "Adventure’s title" | |
) | |
} | |
WhiteRoundedCornerFrame(modifier = Modifier.padding(top = 20.dp)) { | |
PersonalInfoTextField( | |
title = "Description", | |
value = aboutAdventure, | |
onValueChange = { | |
aboutAdventure = it | |
manageAdventureViewModel.sendIntent(AdventuresIntent.OnDescriptionChange(it)) | |
}, | |
modifier = Modifier.padding(top = 0.dp), | |
minLines = 5, | |
mandatory = true, | |
hint = "About Adventure" | |
) | |
} | |
TitleCompose("Date & Time") | |
WhiteRoundedCornerFrame(modifier = Modifier.padding(top = 20.dp)) { | |
Column { | |
ItemTextClickIcon( | |
title = "Start Date", | |
value = manageAdventureState.startDate, // Assuming state name | |
onClick = { | |
showStartDateDialog = true | |
}, | |
content = { | |
Icon( | |
tint = Color(0x8484846E), | |
painter = painterResource(id = R.drawable.ic_date_time), | |
contentDescription = "" | |
) | |
}) | |
HorizontalDivider(color = Color(0xffB9B9BB), modifier = Modifier.fillMaxWidth()) | |
ItemTextClickIcon( | |
title = "End Date", | |
value = manageAdventureState.endDate, // Assuming state name | |
onClick = { | |
showEndDateDialog = true | |
}, | |
content = { | |
Icon( | |
tint = Color(0x8484846E), | |
painter = painterResource(id = R.drawable.ic_date_time), | |
contentDescription = "" | |
) | |
}) | |
} | |
} | |
TitleCompose( | |
text = "Deadline \uDBC0\uDD74 ", isMandatory = false | |
) | |
WhiteRoundedCornerFrame(modifier = Modifier.padding(top = 20.dp)) { | |
ItemTextClickIcon( | |
title = "Deadline", // Changed title | |
value = manageAdventureState.deadlineDate.ifEmpty { null }, // Display selected date | |
onClick = { | |
showDeadlineDateDialog = true | |
}, | |
content = { | |
Icon( | |
tint = Color(0x8484846E), | |
painter = painterResource(id = R.drawable.ic_calendar2), | |
contentDescription = "Set Deadline Date" // Added content description | |
) | |
}) | |
} | |
TitleCompose("Location") | |
GoogleMapWithLocationSearch( | |
modifier = Modifier, | |
searchFieldValue = locationInputText, | |
onSearchFieldValueChange = { locationInputText = it }, | |
locationsPredicted = manageAdventureState.locationsPredicted, | |
newLocation = manageAdventureState.newLocation, | |
isLocationLoading = manageAdventureState.locationIsLoading, | |
onLocationFieldChanged = { query -> | |
manageAdventureViewModel.sendIntent(AdventuresIntent.LocationFieldChanged(query)) | |
}, | |
onLocationSelected = { selectedLocation -> | |
// locationInputText is updated by GoogleMapWithLocationSearch's onLocationSelected callback | |
// which calls onSearchFieldValueChange internally. | |
manageAdventureViewModel.sendIntent( | |
AdventuresIntent.LocationSelected( | |
selectedLocation | |
) | |
) | |
}, | |
onBackPressed = { | |
}, | |
showHeader = false, | |
headerTitle = "", | |
isMapMoving = isMapMoving, | |
onMapClicked = { latLng -> | |
locationInputText = "${latLng.latitude}, ${latLng.longitude}" | |
manageAdventureViewModel.sendIntent(AdventuresIntent.OnLocationLatLngChange(latLng)) | |
} | |
) | |
WhiteRoundedCornerFrame(modifier = Modifier.padding(top = 20.dp)) { | |
// Assuming manageAdventureState.adventureInterests is List<Interest> and Interest has a 'name' property | |
// Also assuming 'Interest' class is imported or accessible. | |
// If adventureInterests can be null, add a null check. | |
val interestsString = if (manageAdventureState.adventureInterests.isNotEmpty()) { | |
manageAdventureState.adventureInterests.joinToString(", ") { it.name } // Or it.toString() if it's List<String> | |
} else { | |
null // Pass null if no interests are selected, ItemTextClickIcon will handle it | |
} | |
MandatoryInterestsComposable( | |
title = "Interests", | |
value = interestsString, | |
onClick = { | |
manageAdventureViewModel.sendIntent(AdventuresIntent.GoInterests) | |
} | |
) | |
} | |
WhiteRoundedCornerFrame(modifier = Modifier.padding(top = 20.dp)) { | |
ItemTextClickIcon( | |
title = "Upload Banner Image", content = { | |
Image( | |
contentDescription = "", | |
painter = painterResource(id = R.drawable.ic_gallery) | |
) | |
}, isMandatory = true | |
) | |
} | |
TitleCompose("Privacy", true) | |
WhiteRoundedCornerFrame(modifier = Modifier.padding(top = 20.dp)) { | |
SelectionList( | |
defaultIndex = manageAdventureState.privacyType, | |
onSelectItem = onSelectPrivacy | |
) | |
} | |
WhiteRoundedCornerFrame(modifier = Modifier.padding(vertical = 20.dp)) { | |
ItemTextClickIcon(title = "Request Condition", isMandatory = true) { | |
Switch( | |
checked = requestCondition, | |
onCheckedChange = { | |
requestCondition = it | |
manageAdventureViewModel.sendIntent( | |
AdventuresIntent.OnRequestConditionChange( | |
it | |
) | |
) | |
}, | |
modifier = Modifier, | |
enabled = true, | |
colors = SwitchDefaults.colors( | |
checkedTrackColor = Color(0xFF34C759), | |
checkedThumbColor = Color.White, | |
uncheckedThumbColor = Color.White, | |
uncheckedTrackColor = Color(0xFFE9E9EB), | |
uncheckedBorderColor = Color(0xFFE9E9EB), | |
checkedBorderColor = Color(0xFF34C759) | |
), | |
) | |
} | |
} | |
ChangerButton( | |
modifier = Modifier | |
.padding( | |
top = 20.dp, bottom = 10.dp, start = 20.dp, end = 20.dp | |
) | |
.fillMaxWidth(), | |
isActive = formIsReady, | |
text = if (manageAdventureState.isPublishing) "Publishing..." else "Publish", | |
deActiveTextColor = Color.White, | |
deActiveButtonColor = Color(0xFFBFBFBF), | |
activeTextColor = Color.White, | |
activeButtonColor = Color(0xFF30D158), // Changed color | |
onClick = { | |
if (formIsReady.value && !manageAdventureState.isPublishing) { | |
manageAdventureViewModel.sendIntent(AdventuresIntent.PublishAdventure) | |
} | |
}) | |
// Show a loading indicator when publishing | |
if (manageAdventureState.isPublishing) { | |
Box( | |
modifier = Modifier | |
.fillMaxWidth() | |
.padding(vertical = 10.dp), | |
contentAlignment = Alignment.Center | |
) { | |
CircularProgressIndicator( | |
color = Color(0xFF30D158), | |
modifier = Modifier.padding(vertical = 8.dp) | |
) | |
} | |
} | |
ChangerButton( | |
modifier = Modifier | |
.padding(top = 0.dp, bottom = 20.dp, start = 20.dp, end = 20.dp) | |
.fillMaxWidth(), | |
isActive = formIsReady, | |
text = "Preview", | |
deActiveTextColor = Color(0xFF848484), | |
deActiveButtonColor = Color.White, | |
activeTextColor = Color(0xFF007AFF), | |
activeButtonColor = Color.White, | |
onClick = { | |
if (formIsReady.value && !manageAdventureState.isPublishing) { | |
manageAdventureViewModel.sendIntent(AdventuresIntent.PreviewAdventure) | |
} | |
}) | |
} | |
} | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\ui\screens\main\add\AdventureInformation.kt | |
```kt | |
package com.divadventure.ui.screens.main.add | |
import androidx.compose.foundation.Image | |
import androidx.compose.foundation.layout.Arrangement | |
import androidx.compose.foundation.layout.Column | |
import androidx.compose.foundation.layout.Row | |
import androidx.compose.foundation.layout.Spacer | |
import androidx.compose.foundation.layout.fillMaxWidth | |
import androidx.compose.foundation.layout.height | |
import androidx.compose.foundation.layout.padding | |
import androidx.compose.foundation.shape.RoundedCornerShape | |
import androidx.compose.material3.Button | |
import androidx.compose.material3.ButtonDefaults | |
import androidx.compose.runtime.Composable | |
import androidx.compose.ui.Alignment | |
import androidx.compose.ui.Modifier | |
import androidx.compose.ui.graphics.Color | |
import androidx.compose.ui.layout.ContentScale | |
import androidx.compose.ui.platform.LocalContext | |
import androidx.compose.ui.unit.dp | |
import coil.compose.rememberAsyncImagePainter | |
import coil.request.CachePolicy | |
import coil.request.ImageRequest | |
import com.divadventure.R | |
import com.divadventure.domain.models.Adventure | |
import com.divadventure.ui.screens.main.home.ProfilesCompose | |
import com.divadventure.viewmodel.AdventuresState | |
import com.divadventure.viewmodel.MainIntent | |
import com.divadventure.viewmodel.MainViewModel | |
/** | |
* A composable function that displays adventure information including banner image, | |
* participant profiles, management button, and detailed information. | |
* | |
* @param adventure The adventure model containing data to display | |
* @param adventureState The state object containing adventure details | |
* @param durationDays The calculated duration of the adventure in days | |
* @param mainViewModel The MainViewModel for handling UI actions | |
* @param onManageParticipants Callback for when the manage participants button is clicked | |
*/ | |
@Composable | |
fun AdventureInformation( | |
adventure: Adventure, | |
adventureState: AdventuresState, | |
durationDays: Int, | |
mainViewModel: MainViewModel? = null, | |
onManageParticipants: (Adventure) -> Unit = {} | |
) { | |
Column { | |
// Banner image | |
Image( | |
modifier = Modifier | |
.fillMaxWidth() | |
.height(200.dp), | |
painter = rememberAsyncImagePainter( | |
model = ImageRequest.Builder(LocalContext.current).data(adventure.banner) | |
.diskCachePolicy(CachePolicy.ENABLED) // Enable disk caching | |
.crossfade(true).build() | |
), | |
contentScale = ContentScale.Crop, | |
contentDescription = "Adventure banner image", | |
) | |
// Adventure details section | |
// Participant profiles and management button | |
Row( | |
modifier = Modifier | |
.fillMaxWidth() | |
.padding(horizontal = 20.dp, vertical = 10.dp), | |
verticalAlignment = Alignment.CenterVertically, | |
horizontalArrangement = Arrangement.SpaceBetween | |
) { | |
ProfilesCompose( | |
list = adventure.adventurers, | |
onClickItem = {} | |
) | |
Spacer(modifier = Modifier.weight(1f)) | |
Button( | |
shape = RoundedCornerShape(4.dp), | |
modifier = Modifier | |
.height(32.dp), | |
onClick = { | |
if (mainViewModel != null) { | |
mainViewModel.sendIntent(MainIntent.GoOwnerParticipantMenu(adventure)) | |
} else { | |
onManageParticipants(adventure) | |
} | |
}, | |
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFB175FF)), | |
) { | |
Image( | |
painter = rememberAsyncImagePainter(model = R.drawable.ic_edit_manage), | |
contentDescription = "Manage participants", | |
modifier = Modifier, | |
contentScale = ContentScale.Fit | |
) | |
} | |
} | |
AdventureDetailsSection( | |
adventureState = adventureState, | |
durationDays = durationDays, | |
) | |
} | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\ui\screens\main\add\AdventureOptions.kt | |
```kt | |
package com.divadventure.ui.screens.main.add | |
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.PaddingValues | |
import androidx.compose.foundation.layout.Row | |
import androidx.compose.foundation.layout.Spacer | |
import androidx.compose.foundation.layout.fillMaxSize | |
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.Arrangement | |
import androidx.compose.material3.CircularProgressIndicator | |
import androidx.compose.foundation.lazy.LazyColumn | |
import androidx.compose.foundation.lazy.items | |
import androidx.compose.foundation.lazy.itemsIndexed | |
import androidx.compose.foundation.shape.CircleShape | |
import androidx.compose.foundation.shape.RoundedCornerShape | |
import androidx.compose.material3.Button | |
import androidx.compose.material3.ButtonDefaults | |
import androidx.compose.material3.Scaffold | |
import androidx.compose.material3.SnackbarHost | |
import androidx.compose.material3.SnackbarHostState | |
import androidx.compose.material3.Text | |
import androidx.compose.material3.TextButton | |
import androidx.compose.runtime.Composable | |
import androidx.compose.runtime.LaunchedEffect | |
import androidx.compose.runtime.collectAsState | |
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.graphics.Color | |
import androidx.compose.ui.text.SpanStyle | |
import androidx.compose.ui.text.TextStyle | |
import androidx.compose.ui.text.buildAnnotatedString | |
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.withStyle | |
import androidx.compose.ui.unit.dp | |
import androidx.compose.ui.unit.sp | |
import androidx.hilt.navigation.compose.hiltViewModel | |
import coil.compose.rememberAsyncImagePainter | |
import com.divadventure.R | |
import com.divadventure.domain.models.Adventure | |
import com.divadventure.domain.models.AdventureRequest | |
import com.divadventure.domain.models.Request // Assuming this is the correct Request model | |
import com.divadventure.ui.BackCompose | |
import com.divadventure.ui.SearchField | |
// import com.divadventure.ui.screens.main.home.notifications.RequestItem // Commented out if not used | |
import com.divadventure.viewmodel.AdventureUIEvent | |
import com.divadventure.viewmodel.AdventuresIntent | |
import com.divadventure.viewmodel.ManageAdventureViewModel | |
@Composable | |
fun AdventureInvitationRequests( | |
padding: PaddingValues, adventure: Adventure | |
) { | |
var searchField by remember { mutableStateOf("") } | |
// var requests = List<FriendRequestUiModel> = adventure.adventureRequest | |
Box( | |
modifier = Modifier | |
.fillMaxSize() | |
.background(Color.White) | |
.padding(padding) | |
) { | |
Scaffold(topBar = { | |
}, content = { innerPadding -> | |
// Add content here later if needed | |
Column { | |
BackCompose( | |
"Invitation Requests " | |
) { } | |
SearchField( | |
queryText = searchField | |
) { | |
searchField = it | |
} | |
FriendRequestSendCancelList( | |
requests = adventure.adventureRequest?.filter { | |
it.type.equals( | |
"inviteRequest", true | |
) | |
} ?: emptyList() | |
) | |
} | |
}) | |
} | |
} | |
@Composable | |
fun FriendRequestSendCancelList(requests: List<AdventureRequest>) { | |
// Use remember for local UI state demo; for production, drive from ViewModel | |
val requestStates = remember { requests.map { mutableStateOf(it.acceptedAt != null) } } | |
LazyColumn { | |
itemsIndexed(requests) { idx, req -> | |
RequestSendCancelItem( | |
imageId = req.user.avatar, | |
userName = req.user.username, | |
date = req.createdAt, | |
isSent = requestStates[idx].value, | |
onActionClick = { currentlySent -> | |
// Toggle phase | |
requestStates[idx].value = !currentlySent | |
// Call your ViewModel or networking logic here | |
} | |
) | |
} | |
} | |
} | |
@Composable | |
fun AdventureJoinRequests( | |
padding: PaddingValues, | |
adventure: Adventure, | |
manageAdventureViewModel: ManageAdventureViewModel // Add this parameter | |
) { | |
val snackbarHostState = remember { SnackbarHostState() } | |
// Use the passed ViewModel instance | |
val uiState by manageAdventureViewModel.state.collectAsState() | |
val joinRequestsList = uiState.joinRequestsList | |
val isLoadingJoinRequests = uiState.isLoadingJoinRequests | |
// val joinRequestsError = uiState.joinRequestsError // Error is handled via Snackbar event | |
LaunchedEffect(adventure.id) { | |
manageAdventureViewModel.sendIntent(AdventuresIntent.FetchJoinRequests(adventure.id)) | |
} | |
LaunchedEffect(Unit) { | |
manageAdventureViewModel.uiEvent.collect { event -> | |
when (event) { | |
is AdventureUIEvent.ShowSnackbar -> { | |
snackbarHostState.showSnackbar( | |
message = event.message, | |
actionLabel = if (event.title.equals("Error", true)) "Dismiss" else null | |
) | |
} | |
// Handle other UI events if necessary | |
else -> {} | |
} | |
} | |
} | |
Scaffold( | |
snackbarHost = { SnackbarHost(snackbarHostState) }, | |
modifier = Modifier | |
.fillMaxSize() | |
.background(Color.White) | |
.padding(padding) | |
) { innerPadding -> | |
Column(modifier = Modifier.padding(innerPadding)) { | |
BackCompose("Join Requests") { | |
// Handle back navigation if needed | |
} | |
if (isLoadingJoinRequests) { | |
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { | |
CircularProgressIndicator() | |
} | |
} else { | |
if (joinRequestsList.isEmpty()) { | |
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { | |
Text("No join requests found.") | |
} | |
} else { | |
LazyColumn(modifier = Modifier.fillMaxSize()) { | |
items(joinRequestsList) { request -> | |
JoinRequestItem( | |
request = request, | |
onAccept = { | |
manageAdventureViewModel.sendIntent( // Use the passed ViewModel | |
AdventuresIntent.AcceptJoinRequest(adventure.id, request.id) | |
) | |
}, | |
onDecline = { | |
manageAdventureViewModel.sendIntent( // Use the passed ViewModel | |
AdventuresIntent.DeclineJoinRequest(adventure.id, request.id) | |
) | |
} | |
) | |
} | |
} | |
} | |
} | |
} | |
} | |
} | |
@Composable | |
fun JoinRequestItem( | |
request: Request, | |
onAccept: () -> Unit, | |
onDecline: () -> Unit | |
) { | |
Row( | |
modifier = Modifier | |
.fillMaxWidth() | |
.padding(horizontal = 16.dp, vertical = 8.dp), | |
verticalAlignment = Alignment.CenterVertically, | |
horizontalArrangement = Arrangement.SpaceBetween | |
) { | |
Row(verticalAlignment = Alignment.CenterVertically) { | |
Image( | |
painter = rememberAsyncImagePainter( | |
model = request.user.avatar ?: R.drawable.img_profile_placeholder // Corrected name | |
), | |
contentDescription = "User Avatar", | |
modifier = Modifier | |
.size(40.dp) | |
.clip(CircleShape) | |
) | |
Spacer(modifier = Modifier.size(8.dp)) | |
Column { | |
Text( | |
text = "${request.user.firstName ?: ""} ${request.user.lastName ?: ""}", | |
fontWeight = FontWeight.Bold | |
) | |
Text( | |
text = "@${request.user.username ?: "N/A"}", | |
fontSize = 12.sp, | |
color = Color.Gray | |
) | |
} | |
} | |
Row { | |
Button( | |
onClick = onAccept, | |
modifier = Modifier.height(36.dp), | |
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF30D158)) | |
) { | |
Text("Accept", color = Color.White, fontSize = 12.sp) | |
} | |
Spacer(modifier = Modifier.size(8.dp)) | |
Button( | |
onClick = onDecline, | |
modifier = Modifier.height(36.dp), | |
colors = ButtonDefaults.buttonColors(containerColor = Color.Red) | |
) { | |
Text("Decline", color = Color.White, fontSize = 12.sp) | |
} | |
} | |
} | |
} | |
/** todo | |
@Composable | |
fun AdventureParticipantManagement(padding: PaddingValues, adventure: Adventure) { | |
var searchField by remember { mutableStateOf("") } | |
Scaffold(topBar = { | |
}, content = { innerPadding -> | |
// Add content here later if needed | |
Column { | |
BackCompose( | |
"Participant Management " | |
) { } | |
SearchField( | |
queryText = searchField | |
) { | |
searchField = it | |
} | |
AdventureParticipantManagement( | |
padding = PaddingValues(0.dp), | |
participants = participants, | |
onRemoveParticipant = { */ | |
/* Remove logic here *//* | |
} | |
) | |
} | |
}) | |
} | |
data class ParticipantUiModel( | |
val id: String, | |
val avatar: String, // image URL | |
val name: String, | |
val username: String | |
) | |
@Composable | |
fun AdventureParticipantManagement( | |
padding: PaddingValues, | |
participants: List<ParticipantUiModel>, | |
onRemoveParticipant: (ParticipantUiModel) -> Unit | |
) { | |
var searchQuery by remember { mutableStateOf("") } | |
val filteredParticipants = remember(searchQuery, participants) { | |
if (searchQuery.isBlank()) participants | |
else participants.filter { | |
it.name.contains(searchQuery, ignoreCase = true) || | |
it.username.contains(searchQuery, ignoreCase = true) | |
} | |
} | |
Scaffold( | |
topBar = { */ | |
/* You can add your topBar here if needed *//* | |
} | |
) { innerPadding -> | |
Column(modifier = Modifier.padding(padding)) { | |
BackCompose("Participant Management") { | |
// Back action here | |
} | |
SearchField( | |
queryText = searchQuery, | |
onQueryChanged = { searchQuery = it } | |
) | |
LazyColumn { | |
items(filteredParticipants) { participant -> | |
ParticipantItem( | |
participant = participant, | |
onRemove = { onRemoveParticipant(participant) } | |
) | |
} | |
} | |
} | |
} | |
} | |
@Composable | |
fun ParticipantItem( | |
participant: ParticipantUiModel, | |
onRemove: () -> Unit | |
) { | |
Row( | |
modifier = Modifier | |
.fillMaxWidth() | |
.padding(horizontal = 20.dp, vertical = 14.dp), | |
verticalAlignment = Alignment.CenterVertically | |
) { | |
Image( | |
painter = rememberAsyncImagePainter(model = participant.avatar), | |
contentDescription = "Participant Avatar", | |
modifier = Modifier | |
.size(48.dp) | |
.clip(RoundedCornerShape(50)) | |
) | |
Column(modifier = Modifier.padding(start = 14.dp)) { | |
Text( | |
text = participant.name, | |
fontWeight = FontWeight.SemiBold, | |
color = Color.Black, | |
fontSize = 16.sp | |
) | |
Text( | |
text = participant.username, | |
color = Color(0xFF999999), | |
fontSize = 13.sp | |
) | |
} | |
Spacer(modifier = Modifier.weight(1f)) | |
TextButton( | |
shape = RoundedCornerShape(6.dp), | |
colors = ButtonDefaults.textButtonColors(containerColor = Color(0xFF30D158)), | |
modifier = Modifier.height(34.dp), | |
onClick = onRemove | |
) { | |
Text( | |
text = "Remove", | |
color = Color.White, | |
fontWeight = FontWeight.SemiBold, | |
fontSize = 15.sp, | |
modifier = Modifier.padding(horizontal = 12.dp) | |
) | |
} | |
} | |
} | |
*/ | |
@Composable | |
fun RequestSendCancelItem( | |
imageId: String, | |
userName: String, | |
date: String, | |
isSent: Boolean, | |
onActionClick: (Boolean) -> Unit // Pass current phase; you handle logic in the caller | |
) { | |
Row( | |
modifier = Modifier | |
.padding(horizontal = 20.dp, vertical = 10.dp) | |
.fillMaxWidth(), | |
verticalAlignment = Alignment.CenterVertically | |
) { | |
Image( | |
painter = rememberAsyncImagePainter(model = imageId), | |
contentDescription = "Friend Request Profile Image", | |
modifier = Modifier | |
.size(50.dp) | |
.clip(RoundedCornerShape(4.dp)) | |
) | |
Column(modifier = Modifier.padding(start = 8.dp)) { | |
Text( | |
text = userName, | |
style = TextStyle( | |
color = Color.Black, | |
fontWeight = FontWeight.SemiBold, | |
fontFamily = FontFamily(Font(R.font.sf_pro)) | |
), | |
fontSize = 16.sp | |
) | |
Text( | |
text = buildAnnotatedString { | |
withStyle( | |
style = SpanStyle( | |
color = Color(0xFF565656), | |
fontFamily = FontFamily(Font(R.font.sf_pro)), | |
fontWeight = FontWeight.Light | |
) | |
) { append(if (isSent) "Friend request sent. " else "Send a friend request. ") } | |
withStyle( | |
style = SpanStyle( | |
color = Color(0xFFAEAEAE), | |
fontFamily = FontFamily(Font(R.font.sf_pro)), | |
fontWeight = FontWeight.Normal | |
) | |
) { append(date) } | |
} | |
) | |
} | |
Spacer(modifier = Modifier.weight(1f, true)) | |
TextButton( | |
modifier = Modifier.height(32.5.dp), | |
shape = RoundedCornerShape(4.dp), | |
colors = ButtonDefaults.textButtonColors( | |
containerColor = if (!isSent) Color(0xFF30D158) else Color(0xFFFFDAD6) | |
), | |
onClick = { onActionClick(isSent) } | |
) { | |
Text( | |
modifier = Modifier | |
.height(16.dp) | |
.padding(horizontal = 16.dp), | |
text = if (!isSent) "Send" else "Cancel", | |
color = if (!isSent) Color.White else Color.Red, | |
style = TextStyle( | |
fontFamily = FontFamily(Font(R.font.sf_pro)), | |
fontWeight = FontWeight.SemiBold, | |
fontSize = 16.sp | |
) | |
) | |
} | |
} | |
} | |
data class FriendRequestUiModel( | |
val imageId: Int, | |
val userName: String, | |
val date: String, | |
var isSent: Boolean // true = request sent, show Cancel; false = not sent, show Send | |
) | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\ui\screens\main\add\AdventurePreview.kt | |
```kt | |
package com.divadventure.ui.screens.main.add | |
import androidx.compose.foundation.background | |
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.fillMaxSize | |
import androidx.compose.foundation.layout.fillMaxWidth | |
import androidx.compose.foundation.layout.padding | |
import androidx.compose.foundation.rememberScrollState | |
import androidx.compose.foundation.shape.RoundedCornerShape | |
import androidx.compose.foundation.verticalScroll | |
import androidx.compose.material3.Icon | |
import androidx.compose.material3.Scaffold | |
import androidx.compose.material3.Text | |
import androidx.compose.runtime.Composable | |
import androidx.compose.runtime.collectAsState | |
import androidx.compose.runtime.derivedStateOf | |
import androidx.compose.runtime.remember | |
import androidx.compose.ui.Alignment | |
import androidx.compose.ui.Modifier | |
import androidx.compose.ui.geometry.Offset | |
import androidx.compose.ui.graphics.Color | |
import androidx.compose.ui.graphics.vector.ImageVector | |
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection | |
import androidx.compose.ui.input.nestedscroll.NestedScrollSource | |
import androidx.compose.ui.input.nestedscroll.nestedScroll | |
import androidx.compose.ui.platform.LocalDensity | |
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.style.TextOverflow | |
import androidx.compose.ui.unit.dp | |
import com.divadventure.R | |
import com.divadventure.data.navigation.NavigationViewModel | |
import com.divadventure.ui.BackCompose | |
import com.divadventure.viewmodel.MainViewModel | |
import com.divadventure.viewmodel.ManageAdventureViewModel | |
@Composable | |
fun AdventurePreview( | |
adventureViewModel: ManageAdventureViewModel, | |
mainViewModel: MainViewModel, | |
navigationViewModel: NavigationViewModel, | |
padding: PaddingValues | |
) { | |
val adventureState = adventureViewModel.state.collectAsState().value | |
val durationDays = remember { | |
derivedStateOf { | |
calculateDurationDays(adventureState.startDate, adventureState.endDate) | |
} | |
} | |
Scaffold( | |
modifier = Modifier | |
.background(Color.White) | |
.fillMaxSize() | |
) { innerPadding -> | |
val scrollState = rememberScrollState() | |
// Create a nested scroll connection to handle nested scrolling properly | |
val nestedScrollConnection = remember { | |
object : NestedScrollConnection { | |
override fun onPreScroll(available: Offset, source: NestedScrollSource) = Offset.Zero | |
} | |
} | |
Column( | |
modifier = Modifier | |
.fillMaxSize() | |
.background(Color.White) | |
.padding(padding) | |
.nestedScroll(nestedScrollConnection) | |
.verticalScroll(scrollState) | |
) { | |
BackCompose( | |
adventureState.adventureTitle | |
) { | |
// Handle back navigation here | |
} | |
/* todo : AsyncImage( | |
model = adventureState.bannerUrl, | |
contentDescription = "Adventure banner image" | |
)*/ | |
// Display adventure details using the new composable | |
AdventureDetailsSection( | |
adventureState = adventureState, | |
durationDays = durationDays.value | |
) | |
} | |
} | |
} | |
@Composable | |
fun InterestChip(text: String) { | |
Box( | |
contentAlignment = Alignment.Center, | |
modifier = Modifier | |
.background( | |
color = Color(0xFFEFEFF4), | |
shape = RoundedCornerShape(16.dp) | |
) | |
.padding(horizontal = 12.dp, vertical = 6.dp) | |
) { | |
Text( | |
text = text, | |
style = TextStyle( | |
fontSize = with(LocalDensity.current) { 9.dp.toSp() }, | |
color = Color(0xFF848484), | |
fontWeight = FontWeight.Medium | |
) | |
) | |
} | |
} | |
/** | |
* A reusable row component for displaying information in the adventure preview screen. | |
* | |
* @param icon The icon to display (optional) | |
* @param iconDescription Content description for the icon | |
* @param text The text to display in the row | |
* @param textColor The color of the text | |
* @param textSize The size of the text in dp | |
* @param showIcon Whether to show the icon or not | |
* @param modifier Additional modifier for customization | |
*/ | |
@Composable | |
fun AdventureInfoRow( | |
icon: ImageVector? = null, | |
iconDescription: String = "", | |
text: String, | |
textColor: Color = Color(0xff2B323A), | |
textSize: androidx.compose.ui.unit.Dp = 9.dp, | |
showIcon: Boolean = true, | |
modifier: Modifier = Modifier | |
) { | |
Row( | |
verticalAlignment = Alignment.CenterVertically, | |
horizontalArrangement = Arrangement.Start, | |
modifier = modifier.padding(horizontal = 20.dp, vertical = 10.dp) | |
) { | |
if (showIcon && icon != null) { | |
Icon( | |
imageVector = icon, | |
contentDescription = iconDescription, | |
tint = Color(0xFF30D158), | |
modifier = Modifier.padding(end = 8.dp) | |
) | |
} | |
Text( | |
text = text, | |
style = TextStyle( | |
color = textColor, | |
fontSize = with(LocalDensity.current) { textSize.toSp() }, | |
fontWeight = FontWeight.W500, | |
), | |
maxLines = 1, | |
overflow = TextOverflow.Ellipsis, | |
modifier = Modifier.weight(1f) | |
) | |
} | |
} | |
/** | |
* A composable that displays the details section of an adventure including: | |
* - Start date | |
* - Location information | |
* - Duration | |
* - Description | |
* - Interests list | |
* - Location map | |
* | |
* @param adventureState The state containing all adventure details | |
* @param durationDays The calculated duration of the adventure in days | |
* @param modifier Additional modifier for customization | |
*/ | |
@Composable | |
fun AdventureDetailsSection( | |
adventureState: com.divadventure.viewmodel.AdventuresState, | |
durationDays: Int, | |
modifier: Modifier = Modifier | |
) { | |
Column(modifier = modifier) { | |
// Start date information | |
AdventureInfoRow( | |
icon = ImageVector.vectorResource(R.drawable.ic_calendar), | |
iconDescription = "Calendar Icon", | |
text = adventureState.startDate | |
) | |
// Location information | |
AdventureInfoRow( | |
icon = ImageVector.vectorResource(id = R.drawable.ic_send), | |
iconDescription = "Location Icon", | |
text = if (adventureState.locationAddress.isNullOrBlank()) { | |
adventureState.locationLat.toString() + " , " + adventureState.locationLng.toString() | |
} else adventureState.locationAddress | |
) | |
// Display duration in days | |
AdventureInfoRow( | |
text = when { | |
durationDays > 0 -> "in ${durationDays} day${if (durationDays > 1) "s" else ""}" | |
durationDays == 0 -> "Same day adventure" | |
else -> "Invalid duration" | |
}, | |
textColor = Color(0xff5856D6), | |
textSize = 11.dp, | |
showIcon = false | |
) | |
// Adventure description | |
Text( | |
modifier = Modifier.padding(horizontal = 20.dp, vertical = 10.dp), | |
text = adventureState.adventureDescription, | |
style = TextStyle( | |
fontSize = with(LocalDensity.current) { 9.dp.toSp() }, | |
color = Color.Black, | |
fontWeight = FontWeight.W400, | |
fontFamily = FontFamily(Font(R.font.sf_pro)) | |
) | |
) | |
// Interests Grid | |
if (adventureState.adventureInterests.isNotEmpty()) { | |
Text( | |
text = "Interests", | |
style = TextStyle( | |
fontSize = with(LocalDensity.current) { 12.dp.toSp() }, | |
color = Color.Black, | |
fontWeight = FontWeight.W600 | |
), | |
modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp) | |
) | |
Box( | |
modifier = Modifier | |
.fillMaxWidth() | |
.padding(horizontal = 20.dp) | |
) { | |
androidx.compose.foundation.layout.FlowRow( | |
modifier = Modifier.fillMaxWidth(), | |
horizontalArrangement = Arrangement.spacedBy(8.dp), | |
verticalArrangement = Arrangement.spacedBy(8.dp) | |
) { | |
adventureState.adventureInterests.forEach { interest -> | |
InterestChip(interest.name) | |
} | |
} | |
} | |
} | |
// Location map | |
if (adventureState.locationLat != 0.0 && adventureState.locationLng != 0.0) { | |
Text( | |
text = "Location", | |
style = TextStyle( | |
fontSize = with(LocalDensity.current) { 12.dp.toSp() }, | |
color = Color.Black, | |
fontWeight = FontWeight.W600 | |
), | |
modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp) | |
) | |
Box( | |
modifier = Modifier | |
.fillMaxWidth() | |
.padding(horizontal = 20.dp, vertical = 8.dp) | |
) { | |
com.divadventure.ui.components.StaticMap( | |
latitude = adventureState.locationLat, | |
longitude = adventureState.locationLng | |
) | |
} | |
} | |
} | |
} | |
/** | |
* Calculates the duration in days between two date strings. | |
* | |
* @param startDate The start date as a string. | |
* @param endDate The end date as a string. | |
* @param dateTimeFormat The date time format to parse the input dates. | |
* @param locale The locale for date formatting (defaults to US). | |
* @return The duration in days, or 0 if there's an error or empty date fields. | |
*/ | |
fun calculateDurationDays( | |
startDate: String, | |
endDate: String, | |
dateTimeFormat: String = "MMM dd yyyy - h:mm a", | |
locale: java.util.Locale = java.util.Locale.US | |
): Int { | |
return try { | |
if (startDate.isNotEmpty() && endDate.isNotEmpty()) { | |
val formatter = java.time.format.DateTimeFormatter.ofPattern(dateTimeFormat, locale) | |
val startLocalDate = java.time.LocalDateTime.parse(startDate, formatter).toLocalDate() | |
val endLocalDate = java.time.LocalDateTime.parse(endDate, formatter).toLocalDate() | |
java.time.temporal.ChronoUnit.DAYS.between(startLocalDate, endLocalDate).toInt() | |
} else { | |
0 | |
} | |
} catch (e: Exception) { | |
println("Date parsing error: ${e.message}") | |
0 // Return 0 for any errors while parsing | |
} | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\ui\screens\main\add\manage\Manage.kt | |
```kt | |
package com.divadventure.ui.screens.main.add.manage | |
import androidx.compose.foundation.background | |
import androidx.compose.foundation.layout.Box | |
import androidx.compose.foundation.layout.Column | |
import androidx.compose.foundation.layout.PaddingValues | |
import androidx.compose.foundation.layout.fillMaxSize | |
import androidx.compose.foundation.layout.fillMaxWidth | |
import androidx.compose.foundation.layout.padding | |
import androidx.compose.foundation.rememberScrollState | |
import androidx.compose.foundation.verticalScroll | |
import androidx.compose.material3.SnackbarDuration | |
import androidx.compose.material3.SnackbarHostState | |
import androidx.compose.material3.Text | |
import androidx.compose.runtime.Composable | |
import androidx.compose.runtime.LaunchedEffect | |
import androidx.compose.runtime.collectAsState | |
import androidx.compose.runtime.derivedStateOf | |
import androidx.compose.runtime.getValue | |
import androidx.compose.runtime.mutableStateOf | |
import androidx.compose.runtime.remember | |
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.text.font.Font | |
import androidx.compose.ui.text.font.FontFamily | |
import androidx.compose.ui.text.font.FontWeight | |
import androidx.compose.ui.unit.dp | |
import androidx.navigation.compose.NavHost | |
import androidx.navigation.compose.composable | |
import androidx.navigation.compose.currentBackStackEntryAsState | |
import androidx.navigation.compose.rememberNavController | |
import com.divadventure.R | |
import com.divadventure.data.navigation.NavigationViewModel | |
import com.divadventure.domain.models.Adventure | |
import com.divadventure.ui.GrayTitle | |
import com.divadventure.ui.screens.main.add.AdventureInformation | |
import com.divadventure.ui.screens.main.add.calculateDurationDays | |
import com.divadventure.ui.screens.main.home.AdventureItemSelection | |
import com.divadventure.viewmodel.AdventuresIntent | |
import com.divadventure.viewmodel.MainUiEvent | |
import com.divadventure.viewmodel.MainViewModel | |
import com.divadventure.viewmodel.ManageAdventureViewModel | |
import kotlinx.coroutines.launch | |
@Composable | |
fun ManageAdventure( | |
padding: PaddingValues, | |
navigationViewModel: NavigationViewModel, mainViewModel: MainViewModel, | |
adventureViewModel: ManageAdventureViewModel, | |
adventure: Adventure | |
) { | |
val snackBarHost = remember { SnackbarHostState() } | |
val navController = rememberNavController() | |
LaunchedEffect(key1 = true) { | |
mainViewModel.uiEvent.collect { event -> | |
when (event) { | |
MainUiEvent.AnimateItem -> { | |
} | |
is MainUiEvent.NavigateToNextScreen -> { | |
navigationViewModel.navigate(event.navigationEvent) | |
} | |
MainUiEvent.ShowDialog -> {} | |
is MainUiEvent.ShowDim -> {} | |
is MainUiEvent.ShowSnackbar -> { | |
launch { | |
snackBarHost.showSnackbar( | |
message = event.message, duration = SnackbarDuration.Long | |
) | |
} | |
} | |
is MainUiEvent.AdventureAction -> {} | |
} | |
} | |
} | |
var adventureTitle: String = "" | |
val adventureParts = listOf<String>("Information", "Task", "Gallery", "Comments", "Expense") | |
var selectedItem by remember { mutableStateOf("Information") } | |
// Update selectedItem when navigation changes | |
val navBackStackEntry by navController.currentBackStackEntryAsState() | |
val currentRoute = navBackStackEntry?.destination?.route | |
LaunchedEffect(currentRoute) { | |
currentRoute?.let { | |
if (adventureParts.contains(it)) { | |
selectedItem = it | |
} | |
} | |
} | |
adventureViewModel.sendIntent(AdventuresIntent.GetAdventure(adventure)) | |
var adventurers = adventure!!.adventurers | |
val adventureState = adventureViewModel.state.collectAsState().value | |
val durationDays = remember { | |
derivedStateOf { | |
calculateDurationDays(adventureState.startDate, adventureState.endDate) | |
} | |
} | |
Box( | |
modifier = Modifier | |
.fillMaxSize() | |
.background(Color.White) | |
.padding(padding) | |
) { | |
Column( | |
modifier = Modifier.verticalScroll(rememberScrollState()) | |
) { | |
GrayTitle(adventure.title) | |
AdventureItemSelection( | |
items = adventureParts, | |
selectedItemId = selectedItem, | |
onItemSelected = { | |
selectedItem = it | |
navController.navigate(it) { | |
// Pop up to the start destination of the graph to | |
// avoid building up a large stack of destinations | |
popUpTo(navController.graph.startDestinationId) { | |
saveState = true | |
} | |
// Avoid multiple copies of the same destination when | |
// reselecting the same item | |
launchSingleTop = true | |
// Restore state when reselecting a previously selected item | |
restoreState = true | |
} | |
} | |
) | |
NavHost( | |
navController = navController, | |
startDestination = adventureParts[0] | |
) { | |
composable(adventureParts[0]) { | |
AdventureInformation( | |
adventure = adventure, | |
adventureState = adventureState, | |
durationDays = durationDays.value, | |
mainViewModel = mainViewModel | |
) | |
} | |
composable(adventureParts[1]) { | |
Box( | |
modifier = Modifier | |
.fillMaxSize() | |
.background(Color.White) | |
) { | |
} | |
} | |
composable(adventureParts[2]) { | |
Box( | |
modifier = Modifier | |
.fillMaxSize() | |
.background(Color.White) | |
) { | |
} | |
} | |
composable(adventureParts[3]) { | |
Box( | |
modifier = Modifier | |
.fillMaxSize() | |
.background(Color.White) | |
) { | |
} | |
} | |
composable(adventureParts[4]) { | |
Box( | |
modifier = Modifier | |
.fillMaxSize() | |
.background(Color.White) | |
) { | |
} | |
} | |
} | |
} | |
} | |
} | |
@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\screens\main\add\ManageAdventure.kt | |
```kt | |
package com.divadventure.ui.screens.main.add | |
import androidx.compose.runtime.Composable | |
@Composable | |
fun OwnerManageAdventure() { | |
} | |
@Composable | |
fun ParticipantManageAdventure() { | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\ui\screens\main\add\ManageAdventureOwnerModerator.kt | |
```kt | |
package com.divadventure.ui.screens.main.add | |
import androidx.compose.foundation.background | |
import androidx.compose.foundation.clickable | |
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.fillMaxSize | |
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.width | |
import androidx.compose.foundation.shape.RoundedCornerShape | |
import androidx.compose.material3.Card | |
import androidx.compose.material3.CardDefaults | |
import androidx.compose.material3.Icon | |
import androidx.compose.material3.MaterialTheme | |
import androidx.compose.material3.Scaffold | |
import androidx.compose.material3.Text | |
import androidx.compose.runtime.Composable | |
import androidx.compose.runtime.LaunchedEffect | |
import androidx.compose.runtime.collectAsState | |
import androidx.compose.ui.Alignment | |
import androidx.compose.ui.Modifier | |
import androidx.compose.ui.graphics.Color | |
import androidx.compose.ui.graphics.vector.ImageVector | |
import androidx.compose.ui.res.vectorResource | |
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.data.navigation.NavigationViewModel | |
import com.divadventure.data.navigation.Screen | |
import com.divadventure.domain.models.Adventure | |
import com.divadventure.ui.BackCompose | |
import com.divadventure.viewmodel.AdventureUIEvent | |
import com.divadventure.viewmodel.AdventuresIntent | |
import com.divadventure.viewmodel.MainViewModel | |
import com.divadventure.viewmodel.ManageAdventureViewModel | |
@Composable | |
fun OwnerParticipantMenu( | |
paddingValues: PaddingValues, | |
adventure: Adventure, | |
mainViewModel: MainViewModel, | |
manageAdventureViewModel: ManageAdventureViewModel, | |
navigationViewModel: NavigationViewModel | |
) { | |
LaunchedEffect(key1 = true) { | |
manageAdventureViewModel.uiEvent.collect { event -> | |
when (event) { | |
is AdventureUIEvent.NavigateToNextScreen -> { | |
navigationViewModel.navigate(event.navigationEvent) | |
} | |
AdventureUIEvent.ShowEndDateDialog -> { | |
} | |
is AdventureUIEvent.ShowSnackbar -> {} | |
AdventureUIEvent.ShowStartDateDialog -> {} | |
AdventureUIEvent.AnimateItem -> {} | |
} | |
} | |
} | |
val manageAdventureState = manageAdventureViewModel.state.collectAsState().value | |
val selectedOption = if (adventure.adventureRequest.isNullOrEmpty() == false) { | |
1 // let's assume index 1 (Join Requests) is selected for demo | |
} else { | |
-1 | |
} | |
var goMenuItem: (screen: Screen) -> Unit = { screen -> | |
when { | |
screen is Screen.AdventureJoinRequests -> { | |
manageAdventureViewModel.sendIntent(AdventuresIntent.GoJoinRequests(adventure)) | |
} | |
} | |
} | |
Box( | |
modifier = Modifier | |
.fillMaxSize() | |
.background(Color.White) | |
.padding(paddingValues) | |
) { | |
Scaffold( | |
containerColor = Color(0xFFEFEFF4), // light background | |
content = { innerPadding -> | |
Column( | |
modifier = Modifier | |
) { | |
BackCompose(text = "Manage ${manageAdventureState.adventureTitle}") { | |
// Handle back press | |
} | |
Spacer(modifier = Modifier.height(20.dp)) | |
ManageOptionList(selectedOption = selectedOption, goMenuItem) | |
} | |
} | |
) | |
} | |
} | |
@Composable | |
fun ManageOptionList(selectedOption: Int, goMenuItem: (Screen) -> Unit) { | |
val options = listOf( | |
ManageOptionData( | |
icon = ImageVector.vectorResource(R.drawable.ic_gear), // replace with your vector if needed | |
title = "Edit Adventure", | |
subtitle = "Update Adventure Details" | |
), | |
ManageOptionData( | |
icon = ImageVector.vectorResource(R.drawable.ic_person_svg), // replace with actual drawable name | |
title = "Join Requests", | |
subtitle = "Participant's Join Request" | |
), | |
ManageOptionData( | |
icon = ImageVector.vectorResource(R.drawable.ic_mail), | |
title = "Invitation Requests", | |
subtitle = "Send Invitation to Participants" | |
), | |
ManageOptionData( | |
icon = ImageVector.vectorResource(R.drawable.ic_moderator), // Changed from AdminPanelSettings which doesn't exist in the imports | |
title = "Moderator Management", | |
subtitle = "Assign/Revoke Moderator Role" | |
), | |
ManageOptionData( | |
icon = ImageVector.vectorResource(R.drawable.ic_participant_management), // Changed from GroupRemove which doesn't exist in the imports | |
title = "Participant Management", | |
subtitle = "Remove Participants" | |
) | |
) | |
options.forEachIndexed { index, data -> | |
ManageOption( | |
data = data, | |
isSelected = index == selectedOption, | |
onClick = { | |
when (index) { | |
0 -> { | |
// Navigate to Edit Adventure Screen | |
} | |
1 -> { | |
// Navigate to Join Requests Screen | |
goMenuItem(Screen.AdventureJoinRequests) | |
} | |
2 -> { | |
// Navigate to Invitation Requests Screen | |
} | |
3 -> { | |
// Navigate to Moderator Management Screen | |
} | |
4 -> { | |
// Navigate to Participant Management Screen | |
} | |
} | |
} | |
) | |
Spacer(modifier = Modifier.height(4.dp)) | |
} | |
} | |
data class ManageOptionData( | |
val icon: ImageVector, | |
val title: String, | |
val subtitle: String | |
) | |
@Composable | |
fun ManageOption( | |
data: ManageOptionData, | |
isSelected: Boolean = false, | |
onClick: () -> Unit = {} | |
) { | |
val backgroundColor = if (isSelected) Color(0xFFDDEAFF) else Color.White | |
val iconColor = if (isSelected) Color(0xFF007AFF) else Color.Black | |
val titleColor = if (isSelected) Color(0xFF007AFF) else Color.Black | |
val subtitleColor = if (isSelected) Color(0xFF007AFF).copy(alpha = 0.8f) else Color.Gray | |
Card( | |
colors = CardDefaults.cardColors( | |
containerColor = Color.White | |
), | |
shape = RoundedCornerShape(10.dp), | |
modifier = Modifier | |
.padding(horizontal = 12.dp) | |
.fillMaxWidth() | |
.background(backgroundColor, RoundedCornerShape(10.dp)) | |
.clickable { onClick() }, | |
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp) | |
) { | |
Row( | |
modifier = Modifier | |
.fillMaxWidth() | |
.padding(vertical = 14.dp, horizontal = 12.dp), | |
verticalAlignment = Alignment.CenterVertically | |
) { | |
Icon( | |
imageVector = data.icon, | |
contentDescription = null, | |
tint = iconColor, | |
modifier = Modifier.size(28.dp) | |
) | |
Spacer(modifier = Modifier.width(16.dp)) | |
Column { | |
Text( | |
text = data.title, | |
color = titleColor, | |
style = MaterialTheme.typography.bodyLarge.copy( | |
fontFamily = FontFamily(Font(R.font.sf_pro)) | |
), | |
fontWeight = FontWeight.SemiBold | |
) | |
Text( | |
text = data.subtitle, | |
color = subtitleColor, | |
style = MaterialTheme.typography.bodyMedium.copy( | |
fontFamily = FontFamily(Font(R.font.sf_pro)) | |
) | |
) | |
} | |
} | |
} | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\ui\screens\main\home\Home.kt | |
```kt | |
package com.divadventure.ui.screens.main.home | |
import androidx.compose.animation.AnimatedVisibility | |
import androidx.compose.animation.core.tween | |
import androidx.compose.animation.slideInHorizontally | |
import androidx.compose.animation.slideOutHorizontally | |
import androidx.compose.foundation.ExperimentalFoundationApi | |
import androidx.compose.foundation.Image | |
import androidx.compose.foundation.background | |
import androidx.compose.foundation.clickable | |
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.wrapContentHeight | |
import androidx.compose.foundation.layout.wrapContentSize | |
import androidx.compose.foundation.lazy.LazyColumn | |
import androidx.compose.foundation.lazy.LazyRow | |
import androidx.compose.foundation.lazy.itemsIndexed | |
import androidx.compose.foundation.lazy.rememberLazyListState | |
import androidx.compose.foundation.shape.CircleShape | |
import androidx.compose.foundation.shape.RoundedCornerShape | |
import androidx.compose.material3.ButtonDefaults | |
import androidx.compose.material3.Card | |
import androidx.compose.material3.CardDefaults | |
import androidx.compose.material3.ExperimentalMaterial3Api | |
import androidx.compose.material3.HorizontalDivider | |
import androidx.compose.material3.Icon | |
import androidx.compose.material3.IconButton | |
import androidx.compose.material3.Scaffold | |
import androidx.compose.material3.Surface | |
import androidx.compose.material3.Text | |
import androidx.compose.material3.TextButton | |
import androidx.compose.runtime.Composable | |
import androidx.compose.runtime.LaunchedEffect | |
import androidx.compose.runtime.MutableState | |
import androidx.compose.runtime.collectAsState | |
import androidx.compose.runtime.getValue | |
import androidx.compose.runtime.mutableFloatStateOf | |
import androidx.compose.runtime.mutableIntStateOf | |
import androidx.compose.runtime.mutableStateOf | |
import androidx.compose.runtime.remember | |
import androidx.compose.runtime.rememberCoroutineScope | |
import androidx.compose.runtime.saveable.rememberSaveable | |
import androidx.compose.runtime.setValue | |
import androidx.compose.runtime.snapshotFlow | |
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.vector.ImageVector | |
import androidx.compose.ui.layout.ContentScale | |
import androidx.compose.ui.layout.onGloballyPositioned | |
import androidx.compose.ui.layout.positionInParent | |
import androidx.compose.ui.layout.positionOnScreen | |
import androidx.compose.ui.platform.LocalConfiguration | |
import androidx.compose.ui.platform.LocalContext | |
import androidx.compose.ui.platform.LocalDensity | |
import androidx.compose.ui.res.painterResource | |
import androidx.compose.ui.res.stringResource | |
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.style.TextAlign | |
import androidx.compose.ui.text.style.TextOverflow | |
import androidx.compose.ui.unit.Dp | |
import androidx.compose.ui.unit.dp | |
import androidx.lifecycle.ViewModel | |
import androidx.navigation.NavHostController | |
import coil.ImageLoader | |
import coil.compose.rememberAsyncImagePainter | |
import coil.request.CachePolicy | |
import coil.request.ImageRequest | |
import com.divadventure.R | |
import com.divadventure.data.navigation.NavigationViewModel | |
import com.divadventure.domain.models.Adventure | |
import com.divadventure.domain.models.AdventureType | |
import com.divadventure.domain.models.Adventurer | |
import com.divadventure.ui.AdventureCalendarItem | |
import com.divadventure.ui.CalendarGuideCompose | |
import com.divadventure.ui.SlidingDualToggleButton | |
import com.divadventure.ui.WeekdayRow | |
import com.divadventure.ui.calendarCirclesSize | |
import com.divadventure.ui.screens.Loader | |
import com.divadventure.ui.screens.main.home.notifications.BottomSheetContent | |
import com.divadventure.ui.screens.main.home.notifications.GeneralBottomSheet | |
import com.divadventure.ui.screens.main.home.notifications.search.Search | |
import com.divadventure.util.Helper.Companion.convertDateString | |
import com.divadventure.util.Helper.Companion.formatDateTime | |
import com.divadventure.viewmodel.HomeIntent | |
import com.divadventure.viewmodel.HomeUiEvent | |
import com.divadventure.viewmodel.HomeViewModel | |
import com.divadventure.viewmodel.MainIntent | |
import com.divadventure.viewmodel.MainUiEvent | |
import com.divadventure.viewmodel.MainViewModel | |
import com.divadventure.viewmodel.ProfileIntent | |
import com.divadventure.viewmodel.ProfileViewModel | |
import com.kizitonwose.calendar.compose.CalendarState | |
import com.kizitonwose.calendar.compose.ContentHeightMode | |
import com.kizitonwose.calendar.compose.HorizontalCalendar | |
import com.kizitonwose.calendar.compose.rememberCalendarState | |
import com.kizitonwose.calendar.core.atStartOfMonth | |
import com.kizitonwose.calendar.core.yearMonth | |
import kotlinx.coroutines.CoroutineScope | |
import kotlinx.coroutines.launch | |
import timber.log.Timber | |
import java.time.DayOfWeek | |
import java.time.LocalDate | |
import java.time.YearMonth | |
import kotlin.math.min | |
// Global constants for UI elements | |
val groupItems = listOf("All", "Created", "Invited", "Joined", "Friends") | |
val showTypes = listOf("List", "Calendar") | |
/** | |
* Composable function that displays the header for the application. | |
* The header includes the application logo, title, notification icon, and search icon. | |
* | |
* @param mainViewModel The instance of [MainViewModel] used to handle user interactions, | |
* such as navigating to the notifications screen. | |
* @param homeViewModel The instance of [HomeViewModel] used to handle toggle logic for | |
* displaying the search bar in the UI. | |
*/ | |
@Composable | |
fun Header( | |
mainViewModel: MainViewModel, homeViewModel: HomeViewModel | |
) { | |
Row( | |
verticalAlignment = Alignment.CenterVertically, | |
modifier = Modifier.padding(horizontal = 20.dp, vertical = 10.dp), | |
) { | |
// Logo for the application, can also have click actions for navigation | |
IconButton( | |
modifier = Modifier.padding(start = 10.dp), onClick = { | |
// Handle logo click if needed | |
}) { | |
Icon( | |
painter = painterResource(id = R.drawable.logo_green), | |
contentDescription = "app_logo", | |
tint = Color.Unspecified | |
) | |
} | |
// Text to display the application title | |
Text( | |
modifier = Modifier | |
.padding(end = 10.dp) | |
.align(Alignment.CenterVertically), | |
text = "DivAdventure", | |
style = TextStyle( | |
fontSize = with(LocalDensity.current) { 16.dp.toSp() }, | |
color = Color.Black, | |
textAlign = TextAlign.Center, | |
fontFamily = FontFamily(Font(R.font.sf_pro)) | |
) | |
) | |
// Spacer to push content (icons) to the end of the row | |
Spacer( | |
modifier = Modifier.weight(1f, true) | |
) | |
// Notifications icon button and click action to navigate or show notifications | |
IconButton( | |
modifier = Modifier.padding(horizontal = 0.dp), onClick = { | |
mainViewModel.sendIntent(MainIntent.GotoNotifications) | |
}) { | |
Icon( | |
tint = Color(0xFF30D158), | |
painter = painterResource(id = R.drawable.ic_ring), | |
contentDescription = "notifications" | |
) | |
} | |
// Search icon button and click action to toggle the search bar in the home UI | |
IconButton( | |
modifier = Modifier.padding(horizontal = 0.dp), onClick = { | |
homeViewModel.sendIntent(HomeIntent.SwitchShowSearchbar) | |
}) { | |
Icon( | |
painter = painterResource(id = R.drawable.ic_search), | |
contentDescription = "placeholder image", | |
tint = Color.Unspecified | |
) | |
} | |
} | |
} | |
/** | |
* Composable function that represents the Home screen of the application. | |
* This function handles UI-related events, manages navigation, and manages the structure of the UI components. | |
* | |
* @param mainViewModel The instance of [MainViewModel] responsible for handling business logic, | |
* state management, and UI events. | |
* @param homeViewModel The instance of [HomeViewModel] handling home screen specific logic. | |
* @param navigationViewModel The instance of [NavigationViewModel] used to manage | |
* navigation-related actions and logic. | |
* @param navController The [NavHostController] instance used to enable navigation | |
* between different composables. | |
* @param padding The [PaddingValues] providing padding for the entire Home screen layout. | |
*/ | |
@OptIn(ExperimentalMaterial3Api::class) | |
@Composable | |
fun Home( | |
mainViewModel: MainViewModel, | |
homeViewModel: HomeViewModel, | |
navigationViewModel: NavigationViewModel, | |
navController: NavHostController, | |
padding: PaddingValues | |
) { | |
// Collect the current state of the home screen | |
val homeState by homeViewModel.state.collectAsState() | |
// Handle UI events and Snackbar | |
LaunchedEffect(Unit) { | |
mainViewModel.uiEvent.collect { event -> | |
when (event) { | |
is MainUiEvent.ShowSnackbar -> { /* Handle Snackbar */ } | |
MainUiEvent.ShowDialog -> { /* Handle Dialog */ } | |
is MainUiEvent.NavigateToNextScreen -> { | |
navigationViewModel.navigate(event.navigationEvent) | |
} | |
// Add this new case: | |
is MainUiEvent.AdventureAction -> { | |
} | |
// MainUiEvent.AnimateItem, is MainUiEvent.ShowDim, is MainUiEvent.ShowSnackbar -> { /* Handle other events */ } // Original line, ensure ShowSnackbar is handled if it was separate | |
MainUiEvent.AnimateItem -> { | |
} | |
is MainUiEvent.ShowDim -> { | |
} | |
} | |
} | |
} | |
LaunchedEffect(key1 = true) { | |
homeViewModel.uiEvent.collect { event -> | |
when (event) { | |
is HomeUiEvent.ShowDim -> {} | |
is HomeUiEvent.ShowSnackbar -> {} | |
HomeUiEvent.AnimateItem -> {} | |
is HomeUiEvent.NavigateToNextScreen -> { | |
navigationViewModel.navigate(event.navigationEvent) | |
} | |
HomeUiEvent.ShowStartDateDialog -> { | |
} | |
HomeUiEvent.ShowEndDateDialog -> { | |
} | |
else -> { | |
// do nothing | |
} | |
} | |
} | |
} | |
Box(modifier = Modifier.padding(padding)) { | |
Scaffold( | |
containerColor = Color(0xFFefeff4), topBar = { | |
AnimatedVisibilityContent( | |
isSearchBarVisible = homeState.isSearchBarVisible, | |
mainViewModel = mainViewModel, | |
homeViewModel = homeViewModel, | |
padding = padding | |
) | |
}) { innerPadding -> | |
var selectedShowType by remember { mutableStateOf(showTypes.first()) } | |
LaunchedEffect(selectedShowType) { | |
homeViewModel.sendIntent( | |
HomeIntent.SwitchCalendarColumn( | |
showTypes.indexOf( | |
selectedShowType | |
) | |
) | |
) | |
} | |
var getCurrentDate: (YearMonth?) -> Unit = { | |
if (selectedShowType == showTypes[1] && it != null) { | |
homeViewModel.sendIntent( | |
HomeIntent.LoadCalendarAdventures( | |
startDate = it.atStartOfMonth().toString(), | |
endDate = it.atEndOfMonth().toString() | |
) | |
) | |
} | |
} | |
BinarySwitcher( | |
modifier = Modifier.padding( | |
bottom = 56.dp, top = innerPadding.calculateTopPadding() | |
), options = showTypes, selectedOption = selectedShowType, onSelectOption = { | |
when (it) { | |
showTypes[0] -> { | |
homeViewModel.sendIntent(HomeIntent.LoadAdventuresData(query = null)) | |
} | |
} | |
selectedShowType = it | |
}, calendarContent = { | |
SelectableCalendar( | |
padding = PaddingValues(), | |
true, | |
getCurrentDate, | |
when (homeState.isSearchBarVisible) { | |
true -> homeState.searchAdventuresList | |
false -> homeState.mainAdventuresList | |
}, | |
homeState.isLoading.calendarIsLoading | |
) | |
}, listContent = { | |
var onClickItem: ((Adventurer) -> Unit) = { adventurer -> | |
mainViewModel.sendIntent(MainIntent.GotoProfile(adventurer.userId)) | |
} | |
AdventuresList(viewModel = homeViewModel, mainViewModel, onClickItem, true) | |
// Use rememberSaveable to persist the flag across recompositions | |
// To fetch adventures in first run | |
val initialLoadPerformed = rememberSaveable { mutableStateOf(false) } | |
LaunchedEffect(Unit) { | |
if (!initialLoadPerformed.value) { | |
homeViewModel.sendIntent(HomeIntent.LoadAdventuresData(query = null)) | |
initialLoadPerformed.value = true | |
} | |
} | |
}) | |
} | |
// It's often better to place the BottomSheet outside the Scaffold's content lambda | |
// if it's meant to overlay everything within this Box scope. | |
if (homeState.showGoingBottomSheet && homeState.selectedAdventureForBottomSheet != null) { | |
GeneralBottomSheet( | |
showBottomSheet = true, // Controlled by homeState.showGoingBottomSheet | |
onDismissRequest = { | |
homeViewModel.sendIntent(HomeIntent.DismissBottomSheet) | |
}, | |
content = { | |
BottomSheetContent( | |
options = listOf("Yes", "No", "Maybe"), | |
onOptionClick = { option -> | |
homeViewModel.sendIntent( | |
HomeIntent.HandleBottomSheetAction( | |
action = option, | |
adventureId = homeState.selectedAdventureForBottomSheet?.id | |
) | |
) | |
}, | |
onCancelClick = { // "Cancel" button in BottomSheetContent usually calls onDismissRequest | |
homeViewModel.sendIntent(HomeIntent.DismissBottomSheet) | |
} | |
// Optional: Provide custom styles if needed for "Yes", "No", "Maybe" | |
) | |
} | |
) | |
} | |
} | |
} | |
/** | |
* Composable function to manage the visibility and animations of two different UI states. | |
* It toggles between a header view with options and a search bar view based on the value of `isSearchBarVisible`. | |
* | |
* @param isSearchBarVisible A nullable Boolean indicating which UI state is visible. | |
* `true` shows the search bar, while `false` shows the header and options. | |
* @param mainViewModel The instance of [MainViewModel] managing the main app logic and state. | |
* @param homeViewModel The instance of [HomeViewModel] managing the home screen logic and state. | |
* @param padding The [PaddingValues] passed to handle padding adjustments for the content. | |
*/ | |
/** | |
* Composable function to manage the visibility and animations of two different UI states. | |
* It toggles between a header view with options and a search bar view. | |
* | |
* @param isSearchBarVisible A nullable Boolean indicating which UI state is visible | |
* @param mainViewModel The main view model for app logic and state | |
* @param homeViewModel The view model for home screen logic and state | |
* @param padding Padding values for content layout adjustments | |
*/ | |
@Composable | |
private fun AnimatedVisibilityContent( | |
isSearchBarVisible: Boolean?, | |
mainViewModel: MainViewModel, | |
homeViewModel: HomeViewModel, | |
padding: PaddingValues | |
) { | |
Box { | |
// Header and list selection view | |
AnimatedVisibility( | |
visible = isSearchBarVisible == false, | |
enter = slideInHorizontally( | |
initialOffsetX = { -it }, | |
animationSpec = tween(durationMillis = 400) | |
), | |
exit = slideOutHorizontally( | |
targetOffsetX = { -it }, | |
animationSpec = tween(durationMillis = 400) | |
) | |
) { | |
Column { | |
// App header with logo, title, and action icons | |
Header(mainViewModel, homeViewModel) | |
// Adventure filter options | |
var selectedItem by remember { mutableStateOf("All") } | |
AdventureItemSelection( | |
items = groupItems, | |
selectedItemId = selectedItem, | |
onItemSelected = { | |
selectedItem = it | |
homeViewModel.sendIntent(HomeIntent.SelectGroup(it)) | |
} | |
) | |
} | |
} | |
// Search bar view | |
AnimatedVisibility( | |
visible = isSearchBarVisible == true, | |
enter = slideInHorizontally( | |
initialOffsetX = { it }, | |
animationSpec = tween(durationMillis = 400) | |
), | |
exit = slideOutHorizontally( | |
targetOffsetX = { it }, | |
animationSpec = tween(durationMillis = 400) | |
) | |
) { | |
Search(mainViewModel, homeViewModel, padding) | |
} | |
} | |
} | |
/** | |
* Composable function that provides a two-option toggle switch | |
* and displays corresponding content based on the selected option. | |
* | |
* @param modifier Modifier to be applied to the composable layout | |
* @param options A list of options to toggle between | |
* @param selectedOption The currently selected option | |
* @param onSelectOption Callback triggered when the selected option changes | |
* @param calendarContent A composable lambda to display calendar-related content | |
* @param listContent A composable lambda to display list-related content | |
*/ | |
@Composable | |
fun BinarySwitcher( | |
modifier: Modifier, | |
options: List<String>, | |
selectedOption: String, | |
onSelectOption: (String) -> Unit, | |
calendarContent: @Composable () -> Unit, | |
listContent: @Composable () -> Unit | |
) { | |
Column(modifier = modifier) { | |
// Toggle button for switching between views | |
SlidingDualToggleButton( | |
padding = 20.dp, | |
options = options, | |
onToggle = { index -> | |
onSelectOption(options[index]) | |
} | |
) | |
// Show content based on selected option | |
AnimatedVisibility(visible = selectedOption == options[0]) { | |
listContent() | |
} | |
AnimatedVisibility(visible = selectedOption == options[1]) { | |
calendarContent() | |
} | |
} | |
} | |
/** | |
* Composable function to display a row of profile images with overlapping arrangement. | |
* If the list contains more than five items, displays a "+N" counter to indicate additional profiles. | |
* | |
* @param list A list of adventurers whose profiles should be displayed | |
* @param onClickItem Callback when an adventurer profile is clicked | |
* @param overlap The horizontal overlap between consecutive profile images. Default is 10.dp | |
*/ | |
@Composable | |
fun ProfilesCompose( | |
list: List<Adventurer>, | |
onClickItem: (Adventurer) -> Unit, | |
overlap: Dp = 10.dp | |
) { | |
LazyRow { | |
// Limit the number of displayed items to a maximum of 5 | |
val displayedItems = min(list.size, 5) | |
// Display each profile item in the list up to the limit | |
itemsIndexed(list.take(displayedItems)) { index, item -> | |
ProfileItem( | |
item = item, | |
overlap = overlap, | |
index = index, | |
onClickItem = onClickItem | |
) | |
} | |
// Display a "+N" counter if there are more than 5 items | |
if (list.size > 5) { | |
item { | |
ExtraProfilesCounter( | |
count = list.size - 5, | |
overlap = overlap, | |
displayedItems = displayedItems | |
) | |
} | |
} | |
} | |
} | |
/** | |
* Composable to display a single profile image with click functionality. | |
* | |
* @param item The adventurer whose profile to display | |
* @param overlap The horizontal overlap to apply | |
* @param index The position index used to calculate the offset | |
* @param onClickItem Callback when the profile is clicked | |
*/ | |
@Composable | |
fun ProfileItem( | |
item: Adventurer, | |
overlap: Dp, | |
index: Int, | |
onClickItem: (Adventurer) -> Unit | |
) { | |
Box( | |
modifier = Modifier | |
.offset(x = -(index * overlap.value).dp) | |
.clickable { onClickItem(item) } | |
) { | |
// Show the profile image or placeholder as appropriate | |
if (item.avatar.isNullOrEmpty()) { | |
ProfilePlaceholder() | |
} else { | |
ProfileImage(resource = item.avatar) | |
} | |
} | |
} | |
/** | |
* Placeholder composable for cases where the profile image is not available. | |
*/ | |
@Composable | |
fun ProfilePlaceholder() { | |
Box( | |
modifier = Modifier | |
.size(40.dp) | |
.background(color = Color.Gray, shape = CircleShape) | |
) | |
} | |
/** | |
* Composable for rendering profile images. | |
* | |
* @param resource The URL of the profile image to display | |
*/ | |
@Composable | |
fun ProfileImage(resource: String) { | |
Image( | |
painter = rememberAsyncImagePainter(model = resource), | |
contentDescription = "Profile Image", | |
contentScale = ContentScale.Crop, | |
modifier = Modifier | |
.size(40.dp) | |
.clip(CircleShape) | |
.background(color = Color.Gray, shape = CircleShape) | |
) | |
} | |
/** | |
* Composable for the "+N" counter that shows how many extra profiles are not displayed. | |
* | |
* @param count The number of additional profiles not shown | |
* @param overlap The horizontal overlap to apply | |
* @param displayedItems The number of items already displayed | |
*/ | |
@Composable | |
fun ExtraProfilesCounter(count: Int, overlap: Dp, displayedItems: Int) { | |
Box(modifier = Modifier.offset(x = -(displayedItems * overlap.value).dp)) { | |
Surface( | |
shape = CircleShape, | |
color = Color.Gray.copy(alpha = 0.5f), | |
modifier = Modifier.size(40.dp) | |
) { | |
Box(contentAlignment = Alignment.Center) { | |
Text( | |
text = "+$count", | |
color = Color.White, | |
textAlign = TextAlign.Center | |
) | |
} | |
} | |
} | |
} | |
/** | |
* Displays an adventure item within a card layout containing an image, title, event details, | |
* participant profiles, and a join button. | |
* | |
* @param item A data object containing the resources necessary to populate the adventure item, | |
* specifically including the image resource ID to display. | |
*/ | |
/** | |
* Displays an adventure item within a card layout containing an image, title, event details, | |
* participant profiles, and a join button. | |
* | |
* @param index The position of this item in a list | |
* @param item The adventure data to display | |
* @param onClickAdventurerItem Callback when an adventurer profile is clicked | |
* @param onSelectAdventure Callback when the action button is clicked | |
*/ | |
@Composable | |
fun AdventureCard( | |
index: Int, | |
item: Adventure, | |
onClickAdventurerItem: (Adventurer) -> Unit, | |
onSelectAdventure: (Adventure) -> Unit | |
) { | |
// Get the appropriate color and text for this adventure type | |
val (buttonColor, buttonText) = when (item.adventureType) { | |
AdventureType.Manage -> Color(0xFF3F51B5) to "Manage" // Indigo | |
AdventureType.Join -> Color(0xFF4CAF50) to "Join" // Green | |
AdventureType.Going -> Color(0xFFFF9800) to "Going" // Orange | |
AdventureType.Pending -> Color(0xFFFF5722) to "Pending" // Deep Orange | |
AdventureType.Leave -> Color(0xFFF44336) to "Leave" // Red | |
null -> Color.Gray to "Unknown" // Fallback | |
} | |
Card( | |
modifier = Modifier | |
.fillMaxWidth() | |
.padding(horizontal = 20.dp, vertical = 10.dp), | |
colors = CardDefaults.cardColors( | |
containerColor = Color.White | |
) | |
) { | |
Column(modifier = Modifier.fillMaxWidth()) { | |
Image( | |
modifier = Modifier | |
.fillMaxWidth() | |
.aspectRatio(1f), | |
alignment = Alignment.Center, | |
contentScale = ContentScale.Crop, | |
painter = rememberAsyncImagePainter( | |
model = ImageRequest.Builder(LocalContext.current).data(item.banner) | |
.diskCachePolicy(CachePolicy.ENABLED) // Enable disk caching | |
.crossfade(true).build() | |
), // Using Coil for image loading | |
contentDescription = "Placeholder image description" | |
) | |
Text( | |
modifier = Modifier.padding(horizontal = 20.dp, vertical = 10.dp), | |
text = item.title, | |
style = TextStyle( | |
fontSize = with(LocalDensity.current) { 16.dp.toSp() }, | |
color = Color(0xFF000000), | |
fontFamily = FontFamily( | |
Font(R.font.sf_pro) | |
), | |
fontWeight = FontWeight.SemiBold | |
) | |
) | |
Row( | |
verticalAlignment = Alignment.CenterVertically, | |
horizontalArrangement = Arrangement.Center, | |
modifier = Modifier.padding(horizontal = 20.dp, vertical = 10.dp) | |
) { | |
// Calendar section | |
Icon( | |
imageVector = ImageVector.vectorResource(R.drawable.ic_calendar), | |
contentDescription = "Calendar Icon", | |
tint = Color(0xFF30D158) | |
) | |
Text( | |
text = item.startsAt.formatDateTime(), | |
modifier = Modifier | |
.weight(1f) | |
.padding(start = 4.dp, end = 8.dp), | |
style = TextStyle(color = Color(0xff2B323A)), | |
maxLines = 1, | |
overflow = TextOverflow.Ellipsis | |
) | |
// Location section | |
Icon( | |
imageVector = ImageVector.vectorResource(id = R.drawable.ic_send), | |
contentDescription = "Location Icon", | |
tint = Color(0xFF30D158) | |
) | |
Text( | |
text = item.description, | |
modifier = Modifier | |
.weight(1f) | |
.padding(start = 4.dp, end = 8.dp), | |
style = TextStyle(color = Color(0xff2B323A)), | |
maxLines = 1, | |
overflow = TextOverflow.Ellipsis | |
) | |
} | |
Row( | |
modifier = Modifier.padding(horizontal = 20.dp, vertical = 10.dp) | |
) { | |
// نشان دهنده ی لیست افراد حاضر در یک رویداد است | |
ProfilesCompose( | |
item.adventurers, onClickAdventurerItem | |
) | |
Spacer(modifier = Modifier.weight(1f)) | |
// با زدن این دکمه می توان به رویداد ملحق شد | |
TextButton( | |
shape = RoundedCornerShape(4.dp), // Example: 8.dp rounded corners | |
colors = ButtonDefaults.buttonColors( | |
containerColor = buttonColor | |
), onClick = { | |
onSelectAdventure(item) | |
}) { | |
Text( | |
modifier = Modifier.padding(horizontal = 10.dp), | |
text = buttonText, | |
maxLines = 1, | |
color = Color.White, | |
style = TextStyle( | |
fontSize = with(LocalDensity.current) { 14.dp.toSp() }, | |
fontWeight = FontWeight.Bold | |
) | |
) | |
} | |
} | |
} | |
} | |
} | |
/** | |
* A composable function that represents a single selectable item in an adventure item list. | |
* | |
* @param title The text displayed for the item. | |
* @param modifier Modifier to be applied to the composable. | |
* @param isSelected Boolean indicating whether the item is currently selected. | |
* @param onSelect Callback that will be triggered when the item is selected. | |
*/ | |
/** | |
* A composable function that represents a single selectable item in an adventure item list. | |
* | |
* @param title The text displayed for the item | |
* @param modifier Modifier to be applied to the composable | |
* @param isSelected Boolean indicating whether the item is currently selected | |
* @param onSelect Callback that will be triggered when the item is selected | |
*/ | |
@Composable | |
fun SingleAdventureItem( | |
title: String, | |
modifier: Modifier, | |
isSelected: Boolean, | |
onSelect: () -> Unit | |
) { | |
Box(modifier = Modifier.clickable { onSelect() }) { | |
Text( | |
text = title, | |
modifier = modifier | |
.background(if (isSelected) Color(0xFF30D158) else Color.White) | |
.padding(10.dp), | |
style = TextStyle( | |
fontSize = with(LocalDensity.current) { 12.dp.toSp() }, | |
color = if (isSelected) Color.White else Color(0xFF848484), | |
textAlign = TextAlign.Center, | |
fontFamily = FontFamily(Font(R.font.sf_pro)) | |
) | |
) | |
} | |
} | |
// برای سوییچ بین انواع رویدادها است، همه رویدادها، رویدادهای ساخته شده توسط ما، رویدادهایی که دعوت شده ایم، رویدادهایی که ملحق شده ایم و رویدادهایی که مربوط به دوستان ما است | |
/** | |
* A composable function that allows selection from a list of adventure-related items. | |
* Displays selectable items horizontally with an indicator for the selected item. | |
* | |
* @param modifier The modifier to be applied to the composable, allowing customization of its appearance and layout. | |
* @param items A list of strings representing the adventure items to be displayed. | |
* @param selectedItemId The identifier for the currently selected item. | |
* @param onItemSelected A callback function triggered when an item is selected, providing the selected item's identifier. | |
*/ | |
/** | |
* A composable function that allows selection from a list of adventure-related items. | |
* Displays selectable items horizontally with an indicator for the selected item. | |
* | |
* @param modifier The modifier to be applied to the composable | |
* @param items A list of strings representing the adventure items to be displayed | |
* @param selectedItemId The identifier for the currently selected item | |
* @param onItemSelected A callback function triggered when an item is selected | |
*/ | |
@Composable | |
fun AdventureItemSelection( | |
modifier: Modifier = Modifier, | |
items: List<String>, | |
selectedItemId: String, | |
onItemSelected: (String) -> Unit | |
) { | |
Box( | |
modifier = Modifier | |
.background(Color.White) | |
.fillMaxWidth() | |
) { | |
Row( | |
modifier = modifier | |
.padding(horizontal = 20.dp) | |
.fillMaxWidth(), | |
verticalAlignment = Alignment.CenterVertically | |
) { | |
items.forEach { item -> | |
SingleAdventureItem( | |
title = item, | |
modifier = Modifier, | |
isSelected = item == selectedItemId, | |
onSelect = { onItemSelected(item) } | |
) | |
// No need for a spacer with 0.dp padding | |
} | |
} | |
} | |
} | |
// راهنمای محتویات نشان داده شده در تقویم است | |
/** | |
* A composable function that displays a guide item consisting of a styled circular item | |
* with text and an accompanying label. | |
* | |
* @param firstItemText The text to be displayed inside the first circular element. | |
* @param firstItemTextColor The color of the text in the first circular element. | |
* @param firstItemColor The background color of the first circular element. | |
* @param secondText The text to be displayed next to the first circular element. | |
*/ | |
@Composable | |
fun GuideItem( | |
firstItemText: String, | |
firstItemTextColor: Color, | |
firstItemColor: Color, | |
secondText: String, | |
count: Int = 0 | |
) { | |
Row( | |
horizontalArrangement = Arrangement.spacedBy(8.dp) // Add horizontal offset of 8.dp between items, | |
, verticalAlignment = Alignment.CenterVertically | |
) { | |
var yOffset by remember { mutableFloatStateOf(0f) } | |
val density = LocalDensity.current // Get the density object | |
AdventureCalendarItem( | |
modifier = Modifier | |
.align(Alignment.CenterVertically) | |
.offset(y = with(density) { (yOffset - (calendarCirclesSize.toPx() / 2)).toDp() }), | |
textColor = firstItemTextColor, | |
text = firstItemText, | |
backgroundColor = firstItemColor, | |
count | |
) | |
Box( | |
modifier = Modifier | |
.align(alignment = Alignment.Top) | |
.padding() | |
.onGloballyPositioned { layoutCoordinates -> | |
yOffset = | |
layoutCoordinates.positionInParent().y + (layoutCoordinates.size.height / 2) | |
}, contentAlignment = Alignment.TopCenter | |
) { | |
Text( | |
modifier = Modifier.wrapContentHeight(), | |
text = secondText, | |
maxLines = 1, | |
style = TextStyle( | |
fontSize = with(LocalDensity.current) { 16.dp.toSp() }, | |
color = Color.Black, | |
fontFamily = FontFamily( | |
Font(R.font.sf_pro) | |
) | |
) | |
) | |
} | |
} | |
} | |
@Composable | |
fun AdventuresList( | |
viewModel: ViewModel, | |
mainViewModel: MainViewModel, | |
onClickItem: ((Adventurer) -> Unit), | |
isScrollable: Boolean | |
) { | |
when (viewModel) { | |
is HomeViewModel -> { | |
val state = viewModel.state.collectAsState().value | |
// adds adventures types to them | |
viewModel.sendIntent(HomeIntent.ApplyAdevntureType) | |
val loadMore = { | |
viewModel.sendIntent(HomeIntent.LoadMoreAdventuresData) | |
} | |
val isLoadingMore = state.isLoading.isLoadingMore | |
AdventuresContent( | |
isScrollable = isScrollable, { adventure -> | |
viewModel.sendIntent(HomeIntent.HandleAdventureClick(adventure)) | |
// mainViewModel.sendIntent(MainIntent.OnSelectAdventure(adventure)) | |
}, | |
loadMore, | |
isLoadingMore, | |
adventures = when (viewModel.state.collectAsState().value.isSearchBarVisible) { | |
true -> { | |
viewModel.state.collectAsState().value.searchAdventuresList | |
} | |
false -> { | |
viewModel.state.collectAsState().value.mainAdventuresList | |
} | |
}, | |
isLoading = state.isLoading.adventuresLoading, | |
onClickItem = onClickItem | |
) | |
} | |
is ProfileViewModel -> { | |
val state = viewModel.state.collectAsState().value | |
// adds adventures types to them | |
viewModel.sendIntent(ProfileIntent.ApplyAdevntureType) | |
val isLoadingMore = state.isLoading.isLoadingMore | |
val loadMore = {} | |
AdventuresContent( | |
isScrollable, { adventure -> | |
viewModel.sendIntent(ProfileIntent.HandleAdventureClick(adventure)) | |
// mainViewModel.sendIntent(MainIntent.OnSelectAdventure(adventure)) | |
}, | |
loadMore, | |
isLoadingMore, | |
adventures = state.adventuresList, | |
isLoading = state.isLoading.adventuresLoading, | |
onClickItem = onClickItem | |
) | |
} | |
else -> throw IllegalArgumentException("Unknown ViewModel type") | |
} | |
} | |
@Composable | |
fun AdventuresContent( | |
isScrollable: Boolean, | |
onSelectAdventure: (Adventure) -> Unit, | |
onLoadMore: () -> Unit, | |
isLoadingMore: Boolean, | |
adventures: List<Adventure>, | |
isLoading: Boolean, | |
onClickItem: (Adventurer) -> Unit | |
) { | |
val listState = rememberLazyListState() | |
// Trigger loading more data when near the end of the list | |
LaunchedEffect(listState) { | |
snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastOrNull() }.collect { lastVisibleItem -> | |
// If the last visible item is near the end of the list, load more | |
if (lastVisibleItem != null && adventures.isNotEmpty() && lastVisibleItem.index >= adventures.size - 2 && !isLoadingMore) { | |
onLoadMore() | |
} | |
} | |
} | |
LazyColumn( | |
userScrollEnabled = isScrollable, modifier = Modifier.height( | |
if (adventures.isNotEmpty()) ((getScreenWidthInDp() + 100.dp) * adventures.size) | |
else 500.dp | |
), state = listState, content = { | |
if (isLoading) { | |
item { | |
Box( | |
modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center | |
) { | |
Loader(modifier = Modifier.size(100.dp)) | |
} | |
} | |
} else itemsIndexed(adventures) { index, item -> | |
AdventureCard( | |
index = index, | |
item = item, | |
onClickAdventurerItem = onClickItem, | |
onSelectAdventure | |
) | |
} | |
// Show loading spinner when fetching more items | |
if (isLoadingMore) { | |
item { | |
Box( | |
modifier = Modifier | |
.fillMaxWidth() | |
.padding(16.dp), | |
contentAlignment = Alignment.Center | |
) { | |
Loader(modifier = Modifier.size(100.dp)) | |
} | |
} | |
} | |
}) | |
} | |
/** | |
* A composable function representing a selectable calendar with event indicators and navigation | |
* functionality. It displays the calendar's current month, allows scrolling between months, | |
* and showcases event items below the calendar. | |
* | |
* @param mainViewModel the main view model to manage the state and handle navigation events or data updates. | |
* @param padding the padding values to adjust the layout of the composable within its parent container. | |
*/ | |
// تقویمی که نشان دهنده ی زمان و تعداد برگزاری اونت ها است و نوع آن ها را نیز نشان می دهد | |
/** | |
* A composable function representing a selectable calendar with event indicators and navigation. | |
* It displays the calendar's current month, allows scrolling between months, | |
* and showcases event items below the calendar. | |
* | |
* @param padding Padding values to adjust the layout within its parent container | |
* @param isScrollable Whether the calendar content should be scrollable | |
* @param onChangeMonth Callback when the visible month changes | |
* @param adventuresList List of adventures to display in the calendar | |
* @param isAdventuresLoading Whether adventures are currently being loaded | |
*/ | |
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) | |
@Composable | |
fun SelectableCalendar( | |
padding: PaddingValues, | |
isScrollable: Boolean, | |
onChangeMonth: ((YearMonth?) -> Unit)? = null, | |
adventuresList: MutableList<Adventure>, | |
isAdventuresLoading: Boolean, | |
) { | |
// Setup coroutine scope for animations | |
val coroutineScope = rememberCoroutineScope() | |
// Initialize calendar state | |
val calendarState = rememberCalendarState( | |
firstVisibleMonth = YearMonth.now(), | |
startMonth = YearMonth.now().minusYears(50), | |
endMonth = YearMonth.now().plusMonths(50) | |
).apply { | |
firstDayOfWeek = DayOfWeek.MONDAY | |
} | |
// Track the current visible month | |
val currentMonth = calendarState.layoutInfo.visibleMonthsInfo | |
.maxByOrNull { it.size }?.month?.yearMonth | |
// Track the selected day | |
val selectedDay = remember { mutableIntStateOf(-1) } | |
// Reset selection when month changes | |
LaunchedEffect(currentMonth) { | |
onChangeMonth?.invoke(currentMonth) | |
selectedDay.intValue = -1 | |
} | |
// Tooltip-related state | |
val tooltipVisible = remember { mutableStateOf(false) } | |
var textWidth by remember { mutableStateOf(0.dp) } | |
var textHeight by remember { mutableStateOf(0.dp) } | |
var absoluteX by remember { mutableStateOf(0.dp) } | |
var absoluteY by remember { mutableStateOf(0.dp) } | |
Card( | |
colors = CardDefaults.cardColors( | |
containerColor = Color.White | |
), modifier = Modifier.padding(horizontal = 20.dp) | |
) { | |
LazyColumn( | |
userScrollEnabled = isScrollable, | |
verticalArrangement = Arrangement.Top, | |
modifier = Modifier.height((70.dp) * (adventuresList.size) + getScreenWidthInDp()) | |
) { | |
item( | |
key = 0, | |
) { | |
// CompositionLocalProvider { | |
CalendarContent( | |
coroutineScope = coroutineScope, | |
calendarState = calendarState, | |
adventuresList = adventuresList, | |
currentMonth = currentMonth, | |
tooltipVisible = tooltipVisible, | |
selectedDay = selectedDay, | |
onTooltipPositionUpdate = { x, y, width, height -> | |
textWidth = width | |
textHeight = height | |
absoluteX = x | |
absoluteY = y | |
}) | |
// } | |
} | |
if (isAdventuresLoading) { | |
item { | |
Box( | |
modifier = Modifier | |
.wrapContentHeight() | |
.fillMaxWidth() | |
) { | |
Loader( | |
modifier = Modifier | |
.height(50.dp) | |
.align(Alignment.Center) | |
) | |
} | |
} | |
} | |
val itemsList = adventuresList | |
itemsIndexed(itemsList.filter { | |
!(selectedDay.value > 0) || it?.startsAt?.convertDateString() | |
?.split(" ")?.get(1)?.toInt() == selectedDay.value | |
}) { index, adventure -> | |
// else { | |
// Safely access the adventure item | |
adventure?.let { | |
AdventureEventItem(it) // Use the actual event data here | |
} | |
// } | |
} | |
} | |
} | |
CalendarGuideCompose( | |
tooltipVisible, | |
absoluteX - textWidth, | |
absoluteY - padding.calculateBottomPadding() + textHeight / 2 // + textHeight - padding.calculateBottomPadding() | |
) | |
} | |
@Composable | |
fun CalendarContent( | |
modifier: Modifier = Modifier, | |
coroutineScope: CoroutineScope, | |
adventuresList: List<Adventure>, | |
calendarState: CalendarState, | |
currentMonth: YearMonth?, | |
tooltipVisible: MutableState<Boolean>, | |
selectedDay: MutableState<Int>, | |
onTooltipPositionUpdate: (Dp, Dp, Dp, Dp) -> Unit | |
) { | |
Column( | |
modifier = modifier.padding(start = 20.dp, end = 20.dp, top = 20.dp), | |
horizontalAlignment = Alignment.CenterHorizontally | |
) { | |
// Calendar Header | |
Row( | |
modifier = Modifier.padding(vertical = 0.dp, horizontal = 10.dp), | |
verticalAlignment = Alignment.CenterVertically | |
) { | |
// Display the current month and year | |
Text( | |
style = TextStyle( | |
fontWeight = FontWeight.SemiBold, | |
color = Color(0xFF1C1C1E), | |
fontSize = with(LocalDensity.current) { 14.dp.toSp() }), | |
text = currentMonth?.month?.name ?: "" | |
) | |
Text( | |
modifier = Modifier | |
.align(Alignment.CenterVertically) | |
.padding(start = 5.dp), | |
style = TextStyle( | |
fontWeight = FontWeight.SemiBold, | |
color = Color(0xFF1C1C1E), | |
fontSize = with(LocalDensity.current) { 14.dp.toSp() }), | |
text = currentMonth?.year.toString() | |
) | |
// Tooltip for additional information | |
InfoText( | |
modifier = Modifier, | |
text = stringResource(id = R.string.info_symbol), | |
isTooltipVisible = tooltipVisible.value, | |
onTooltipToggle = { tooltipVisible.value = !tooltipVisible.value }, | |
onPositionUpdate = onTooltipPositionUpdate | |
) | |
Spacer(modifier = Modifier.weight(1f)) | |
// Navigation Buttons (Left and Right) | |
CalendarNavigationButton( | |
modifier = Modifier | |
.align(Alignment.CenterVertically) | |
.padding(horizontal = 40.dp), | |
imageResId = R.drawable.ic_calendar_left_arrow, | |
contentDescription = "" | |
) { | |
coroutineScope.launch { | |
calendarState.animateScrollToMonth( | |
calendarState.firstVisibleMonth.yearMonth.minusMonths(1) | |
) | |
} | |
} | |
CalendarNavigationButton( | |
modifier = Modifier.align(Alignment.CenterVertically), | |
imageResId = R.drawable.ic_calendar_right_arrow, | |
contentDescription = "" | |
) { | |
coroutineScope.launch { | |
calendarState.animateScrollToMonth( | |
calendarState.firstVisibleMonth.yearMonth.plusMonths(1) | |
) | |
} | |
} | |
} | |
// Divider | |
HorizontalDivider( | |
color = Color(0xFFE4E5E7), | |
thickness = 1.dp, | |
modifier = Modifier | |
.fillMaxWidth() | |
.padding(vertical = 20.dp, horizontal = 10.dp) | |
.background(Color(0xFFE4E5E7)) | |
) | |
// Display the Weekday Row and the Calendar | |
WeekdayRow() | |
AdventureCalendar( | |
modifier = Modifier, | |
adventuresList, | |
calendarState = calendarState, | |
currentMonth = currentMonth, | |
selectedDay | |
) | |
} | |
} | |
@Composable | |
fun AdventureCalendar( | |
modifier: Modifier = Modifier, | |
adventuresList: List<Adventure>, | |
calendarState: CalendarState, | |
currentMonth: YearMonth?, | |
selectedDay: MutableState<Int> | |
) { | |
val sortedAdventures = adventuresList.groupBy { it.startsAt.convertDateString() } | |
// var selectedDay by remember { mutableIntStateOf(-1) } | |
HorizontalCalendar( | |
state = calendarState, | |
modifier = modifier | |
.wrapContentSize() | |
.padding(top = 10.dp), | |
dayContent = { day -> | |
Box( | |
contentAlignment = Alignment.Center, | |
modifier = Modifier | |
.fillMaxWidth() | |
.padding(0.dp) | |
.background(Color.White) | |
) { | |
/* LaunchedEffect( | |
calendarState.firstVisibleMonth | |
) { | |
selectedDay.value = -1 | |
} | |
LaunchedEffect( | |
calendarState.lastVisibleMonth | |
) { | |
selectedDay.value = -1 | |
} | |
*/ | |
val isNowDay = LocalDate.now().dayOfMonth == day.date.dayOfMonth | |
val isNowMonth = YearMonth.now().monthValue == day.date.monthValue | |
val isNowYear = LocalDate.now().year == day.date.year | |
val isNowDate = isNowMonth && isNowDay && isNowYear | |
if (day.date.yearMonth.month.value == currentMonth?.month?.value) { | |
val day = day.date.dayOfMonth | |
Timber.d("Processing day: $day") | |
val adventuresCoDay = | |
sortedAdventures.filter { it.key.split(" ")[1].toInt() == day }.values.firstOrNull() | |
val occur = adventuresCoDay?.size ?: 0 | |
Timber.d("Occurrences for day $day: $occur") | |
var onSelect = { | |
selectedDay.value = day | |
} | |
val isSelected = day == selectedDay.value | |
val selectedBackgroundColor = Color(0xFF5856D6) | |
when { | |
isNowDate -> { | |
AdventureCalendarItem( | |
text = day.toString(), | |
textColor = Color.White, | |
backgroundColor = if (isSelected) selectedBackgroundColor else Color( | |
0xFF30D158 | |
), | |
countColor = Color(0xFF30D158), | |
count = occur, | |
onSelect = onSelect, | |
isSelected = isSelected | |
) | |
} | |
occur > 1 -> { | |
Timber.d("Day $day has more than 1 event.") | |
AdventureCalendarItem( | |
text = day.toString(), | |
textColor = if (isSelected) Color.White | |
else Color(0xFF30D158), | |
backgroundColor = if (isSelected) selectedBackgroundColor else Color.White, | |
countColor = Color(0xFF30D158), | |
count = occur, | |
onSelect = onSelect, | |
isSelected = isSelected | |
) | |
} | |
occur == 1 -> { | |
Timber.d("Day $day has 1 event.") | |
when (adventuresCoDay!!.first().state) { | |
"upcoming" -> { | |
AdventureCalendarItem( | |
text = day.toString(), | |
textColor = Color.Black, | |
backgroundColor = if (isSelected) selectedBackgroundColor else Color( | |
0xFFF5E2C6 | |
), | |
onSelect = onSelect, | |
isSelected = isSelected | |
) | |
} | |
"past" -> { | |
AdventureCalendarItem( | |
text = day.toString(), | |
textColor = Color.Black, | |
backgroundColor = if (isSelected) selectedBackgroundColor else Color( | |
0xFFD7D7DF | |
), | |
onSelect = onSelect, | |
isSelected = isSelected | |
) | |
} | |
"active" -> { | |
AdventureCalendarItem( | |
text = day.toString(), | |
textColor = Color.Black, | |
backgroundColor = if (isSelected) selectedBackgroundColor else Color( | |
0xFFC5EACF | |
), | |
onSelect = onSelect, | |
isSelected = isSelected | |
) | |
} | |
} | |
} | |
occur == 0 -> { | |
Timber.d("Day $day has no events.") | |
AdventureCalendarItem( | |
text = day.toString(), | |
textColor = if (isSelected) Color.White else Color(0xff1C1C1E), | |
backgroundColor = if (isSelected) selectedBackgroundColor else Color.White, | |
onSelect = onSelect, | |
isSelected = isSelected | |
) | |
} | |
} | |
} | |
// } | |
} | |
}, | |
userScrollEnabled = true, | |
calendarScrollPaged = true, | |
contentHeightMode = ContentHeightMode.Wrap | |
) | |
} | |
/** | |
* A composable function that displays information text with tooltip functionality. | |
* When clicked, it toggles a tooltip and provides position information for proper placement. | |
* | |
* @param modifier The modifier to be applied to the composable | |
* @param text The text to be displayed | |
* @param isTooltipVisible Boolean indicating whether the tooltip is currently visible | |
* @param onTooltipToggle Callback for toggling tooltip visibility | |
* @param onPositionUpdate Callback that provides position data for tooltip placement | |
*/ | |
@Composable | |
fun InfoText( | |
modifier: Modifier = Modifier, | |
text: String, | |
isTooltipVisible: Boolean, | |
onTooltipToggle: (Boolean) -> Unit, | |
onPositionUpdate: (Dp, Dp, Dp, Dp) -> Unit | |
) { | |
val density = LocalDensity.current | |
Text( | |
text = text, | |
modifier = modifier | |
.padding(start = 5.dp) | |
.clickable { onTooltipToggle(!isTooltipVisible) } | |
.onGloballyPositioned { layoutCoordinates -> | |
// Calculate position measurements for tooltip placement | |
val textWidth = with(density) { layoutCoordinates.size.width.toFloat().toDp() } | |
val textHeight = with(density) { layoutCoordinates.size.height.toFloat().toDp() } | |
val absoluteX = with(density) { layoutCoordinates.positionOnScreen().x.toDp() } | |
val absoluteY = with(density) { layoutCoordinates.positionOnScreen().y.toDp() } | |
// Update tooltip position | |
onPositionUpdate(absoluteX, absoluteY, textWidth, textHeight) | |
}, | |
style = TextStyle( | |
fontSize = with(LocalDensity.current) { 16.dp.toSp() }, | |
color = Color(0xFF1C1C1E), | |
fontWeight = FontWeight.Bold | |
) | |
) | |
} | |
/* | |
@OptIn(ExperimentalMaterial3Api::class) | |
@Composable | |
fun CalendarCard( | |
calendarState: CalendarState, // Passed as a parameter | |
modifier: Modifier = Modifier, | |
onPrevMonthClick: () -> Unit, | |
onNextMonthClick: () -> Unit, | |
currentMonth: YearMonth?, | |
tooltipVisible: MutableState<Boolean>, | |
onTooltipPositionUpdate: (Dp, Dp, Dp, Dp) -> Unit | |
) { | |
Card( | |
colors = CardDefaults.cardColors(containerColor = Color.White), | |
modifier = modifier.padding(horizontal = 20.dp) | |
) { | |
Column( | |
modifier = Modifier.padding(start = 20.dp, end = 20.dp, top = 20.dp), | |
horizontalAlignment = Alignment.CenterHorizontally | |
) { | |
// Calendar header with month name, year, and navigation controls | |
Row( | |
modifier = Modifier.padding(vertical = 0.dp, horizontal = 10.dp), | |
verticalAlignment = Alignment.CenterVertically | |
) { | |
Text( | |
text = currentMonth?.month?.name ?: "", style = TextStyle( | |
fontWeight = FontWeight.SemiBold, | |
color = Color(0xFF1C1C1E), | |
fontSize = with(LocalDensity.current) { 14.dp.toSp() }) | |
) | |
Text( | |
text = currentMonth?.year.toString(), | |
style = TextStyle( | |
fontWeight = FontWeight.SemiBold, | |
color = Color(0xFF1C1C1E), | |
fontSize = with(LocalDensity.current) { 14.dp.toSp() }), | |
modifier = Modifier | |
.align(Alignment.CenterVertically) | |
.padding(start = 5.dp) | |
) | |
InfoText( | |
text = stringResource(id = R.string.info_symbol), | |
isTooltipVisible = tooltipVisible.value, | |
modifier = Modifier, | |
onTooltipToggle = { tooltipVisible.value = !tooltipVisible.value }, | |
onPositionUpdate = onTooltipPositionUpdate | |
) | |
Spacer(modifier = Modifier.weight(1f)) | |
CalendarNavigationButton( | |
imageResId = R.drawable.ic_calendar_left_arrow, | |
contentDescription = "", | |
modifier = Modifier | |
.align(Alignment.CenterVertically) | |
.padding(horizontal = 40.dp), | |
onClick = onPrevMonthClick | |
) | |
CalendarNavigationButton( | |
imageResId = R.drawable.ic_calendar_right_arrow, | |
contentDescription = "", | |
modifier = Modifier.align(Alignment.CenterVertically), | |
onClick = onNextMonthClick | |
) | |
} | |
// Divider | |
HorizontalDivider( | |
color = Color(0xFFE4E5E7), | |
thickness = 1.dp, | |
modifier = Modifier | |
.fillMaxWidth() | |
.padding(vertical = 20.dp, horizontal = 10.dp) | |
) | |
// Weekday names and calendar layout | |
WeekdayRow() | |
AdventureCalendar( | |
adventuresList = emptyList(), | |
calendarState = calendarState, | |
currentMonth = currentMonth | |
) | |
} | |
} | |
} | |
*/ | |
@Composable | |
fun CalendarNavigationButton( | |
modifier: Modifier = Modifier, | |
imageResId: Int, | |
contentDescription: String, | |
onClick: suspend () -> Unit | |
) { | |
val coroutineScope = rememberCoroutineScope() | |
Image( | |
modifier = modifier/* | |
.align(Alignment.CenterVertically) | |
*//* | |
.padding(horizontal = 40.dp) | |
*/.clickable { | |
coroutineScope.launch { | |
onClick() | |
} | |
}, painter = painterResource(id = imageResId), contentDescription = contentDescription | |
) | |
} | |
/** | |
* Represents an item in the calendar view indicating an event and its associated date. | |
* | |
* @param imageId Resource ID of the image to display alongside the event item. | |
* @param eventName Name or title of the adventure event to be displayed. | |
* @param eventDate Date or time of the adventure event to be displayed. | |
*/ | |
// نشان دهنده ی آیتم موجود در تقویم و این که زمان برگزاری آن چه موقع است | |
/** | |
* Represents an item in the calendar view indicating an event and its associated date. | |
* | |
* @param adventure The adventure data to display in the calendar item | |
*/ | |
@Composable | |
fun AdventureEventItem(adventure: Adventure) { | |
Box( | |
modifier = Modifier | |
.height(70.dp) | |
.padding(5.dp), | |
contentAlignment = Alignment.Center | |
) { | |
Row( | |
modifier = Modifier.fillMaxWidth(), | |
verticalAlignment = Alignment.CenterVertically | |
) { | |
// Adventure thumbnail image | |
Image( | |
painter = rememberAsyncImagePainter( | |
model = adventure.banner, | |
imageLoader = ImageLoader.Builder(LocalContext.current) | |
.diskCachePolicy(CachePolicy.ENABLED) | |
.build() | |
), | |
contentDescription = "Adventure thumbnail", | |
modifier = Modifier | |
.size(76.dp) | |
.padding(10.dp) | |
.clip(RoundedCornerShape(8.dp)), | |
contentScale = ContentScale.Crop | |
) | |
// Content section with title and date | |
Box(modifier = Modifier.weight(1f)) { | |
HorizontalDivider( | |
thickness = 1.dp, | |
modifier = Modifier.height(1.dp), | |
color = Color(0xFFEAEAEA) | |
) | |
Column( | |
modifier = Modifier.fillMaxHeight(), | |
verticalArrangement = Arrangement.SpaceEvenly | |
) { | |
// Title text | |
Text( | |
text = adventure.title, | |
modifier = Modifier.padding(horizontal = 10.dp), | |
style = TextStyle( | |
color = Color.Black, | |
fontSize = with(LocalDensity.current) { 14.dp.toSp() }, | |
fontWeight = FontWeight.SemiBold, | |
fontFamily = FontFamily(Font(R.font.sf_pro)) | |
) | |
) | |
// Date range text | |
Text( | |
text = "${adventure.startsAt.convertDateString()} - ${adventure.endsAt.convertDateString()}", | |
modifier = Modifier.padding(horizontal = 8.dp), | |
style = TextStyle( | |
fontSize = with(LocalDensity.current) { 12.dp.toSp() }, | |
color = Color(0xFFA3A3A3), | |
fontFamily = FontFamily(Font(R.font.sf_pro)) | |
) | |
) | |
} | |
// Chevron indicator | |
Image( | |
painter = painterResource(id = R.drawable.right_chevron), | |
contentDescription = "View details", | |
modifier = Modifier | |
.align(Alignment.CenterEnd) | |
.padding(end = 10.dp) | |
) | |
} | |
} | |
} | |
} | |
@Composable | |
fun getScreenWidthInDp(): Dp { | |
val configuration = LocalConfiguration.current | |
return configuration.screenWidthDp.dp | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\ui\screens\main\home\notifications\Notifications.kt | |
```kt | |
package com.divadventure.ui.screens.main.home.notifications | |
import androidx.activity.OnBackPressedCallback | |
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner | |
import androidx.compose.foundation.Image | |
import androidx.compose.foundation.background | |
import androidx.compose.foundation.clickable | |
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.padding | |
import androidx.compose.foundation.layout.size | |
import androidx.compose.foundation.layout.wrapContentHeight | |
import androidx.compose.foundation.lazy.LazyColumn | |
import androidx.compose.foundation.shape.RoundedCornerShape | |
import androidx.compose.material3.ButtonDefaults | |
import androidx.compose.material3.Card | |
import androidx.compose.material3.CardDefaults | |
import androidx.compose.material3.ExperimentalMaterial3Api | |
import androidx.compose.material3.HorizontalDivider | |
import androidx.compose.material3.ModalBottomSheet | |
import androidx.compose.material3.Scaffold | |
import androidx.compose.material3.SheetValue | |
import androidx.compose.material3.Text | |
import androidx.compose.material3.TextButton | |
import androidx.compose.material3.rememberModalBottomSheetState | |
import androidx.compose.runtime.Composable | |
import androidx.compose.runtime.DisposableEffect | |
import androidx.compose.runtime.LaunchedEffect | |
import androidx.compose.runtime.collectAsState | |
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.Alignment | |
import androidx.compose.ui.Modifier | |
import androidx.compose.ui.draw.clip | |
import androidx.compose.ui.graphics.Color | |
import androidx.compose.ui.layout.ContentScale | |
import androidx.compose.ui.res.painterResource | |
import androidx.compose.ui.text.SpanStyle | |
import androidx.compose.ui.text.TextStyle | |
import androidx.compose.ui.text.buildAnnotatedString | |
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.withStyle | |
import androidx.compose.ui.unit.dp | |
import androidx.compose.ui.unit.sp | |
import coil.compose.rememberAsyncImagePainter | |
import com.divadventure.R | |
import com.divadventure.data.navigation.NavigationEvent | |
import com.divadventure.data.navigation.NavigationViewModel | |
import com.divadventure.ui.BackCompose | |
import com.divadventure.viewmodel.NotificationsIntent | |
import com.divadventure.viewmodel.NotificationsUiEvent | |
import com.divadventure.viewmodel.NotificationsViewModel | |
// این کامپوز مانند صفحه ی نتویفیکشن اینستگرام است، و می توان در آن لیست درخواستهای فالو کردن و همچنین درخواست همراهی در ماجراجویی ها را مشاهده کرد همچنین یادآوری برگزاری رویدادها هم در اینجا ممکن است | |
@OptIn(ExperimentalMaterial3Api::class) | |
@Composable | |
fun Notifications( | |
notificationsViewModel: NotificationsViewModel, | |
navigationViewModel: NavigationViewModel, | |
padding: PaddingValues | |
) { | |
val state by notificationsViewModel.state.collectAsState() | |
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) { | |
notificationsViewModel.uiEvent.collect { event -> | |
when (event) { | |
NotificationsUiEvent.AnimateItem -> { | |
} | |
is NotificationsUiEvent.NavigateToNextScreen -> { | |
} | |
NotificationsUiEvent.ShowBottomSheet -> { | |
showBottomSheet = true | |
} | |
NotificationsUiEvent.ShowDialog -> { | |
} | |
is NotificationsUiEvent.ShowSnackbar -> { | |
} | |
} | |
} | |
} | |
// Get the Back Press Dispatcher | |
val backPressedDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher | |
// Register the Back Press Callback | |
DisposableEffect(backPressedDispatcher) { | |
val callback = object : OnBackPressedCallback(true) { | |
override fun handleOnBackPressed() { | |
// Trigger PopSpecific to navigate back without additional NavigateTo call | |
navigationViewModel.navigate( | |
NavigationEvent.PopBackStack | |
) | |
} | |
} | |
backPressedDispatcher?.addCallback(callback) | |
// Cleanup callback | |
onDispose { | |
callback.remove() | |
} | |
} | |
if (showBottomSheet) { | |
// باتم شیتی که در آن می توان درخواست پیوستن به یک رویداد از طرف کاربری دیگر یا ماجراجویی را پذیرفت یا رد کرد | |
GeneralBottomSheet( | |
showBottomSheet = showBottomSheet, | |
onDismissRequest = { showBottomSheet = false }, | |
content = { | |
BottomSheetContent( | |
listOf("Yes", "Maybe", "No"), | |
onOptionClick = { option -> | |
// Handle option click (yes, maybe, no) | |
println("Option selected: $option") | |
showBottomSheet = false | |
}, | |
onCancelClick = { | |
// Handle cancel click | |
println("Cancel clicked") | |
showBottomSheet = false | |
} | |
) | |
} | |
) | |
} | |
Scaffold( | |
containerColor = Color.White | |
) { paddingValues -> | |
Box(modifier = Modifier.padding(paddingValues)) { | |
Column { | |
BackCompose("Notifications") { | |
backPressedDispatcher?.onBackPressed() | |
} | |
LazyColumn( | |
) { | |
item { | |
NotificationTimeCategory("This Week") | |
} | |
item { | |
NotificationAdventureItem( | |
R.drawable.landing1, "Camping", "accepted your join request. ", "3h ago" | |
) | |
} | |
item { | |
TimeCategoryDivider() | |
} | |
item { | |
NotificationTimeCategory("This Month") | |
} | |
item { | |
RequestItem( | |
imageId = R.drawable.random_image_2, | |
userName = "John Doe", | |
date = "2d ago" | |
) | |
} | |
item { | |
NotificationMentionItem( | |
imageId = R.drawable.random_image_3, | |
userName = "Jane Smith", | |
date = "5h ago" | |
) | |
} | |
item { | |
RequestItem( | |
imageId = R.drawable.random_image_3, | |
userName = "Default User", | |
date = "8h ago" | |
) | |
} | |
item { | |
NotificationInvitationItem( | |
notificationsViewModel, | |
imageId = R.drawable.random_image_4, | |
userName = "Alice Anderson", | |
date = "12h ago" | |
) | |
} | |
} | |
} | |
} | |
} | |
} | |
/** | |
* Displays a notification invitation item, showing user information, a message, and a button | |
* to handle the response to the invitation. | |
* | |
* @param notificationsViewModel The [NotificationsViewModel] instance used to handle actions. | |
* @param imageId The resource ID of the image to display as the user's profile picture. | |
* @param userName The name of the user who sent the invitation. | |
* @param date The date associated with the invitation. | |
*/ | |
@Composable | |
fun NotificationInvitationItem( | |
notificationsViewModel: NotificationsViewModel, | |
imageId: Int, | |
userName: String, | |
date: String | |
) { | |
Row( | |
modifier = Modifier | |
.padding(horizontal = 20.dp, vertical = 10.dp) | |
.fillMaxWidth(), | |
verticalAlignment = Alignment.CenterVertically, | |
horizontalArrangement = Arrangement.spacedBy(5.dp) | |
) { | |
Image( | |
painter = painterResource(id = imageId), | |
contentDescription = "Friend Request Profile Image", | |
modifier = Modifier | |
.size(50.dp) | |
.clip(RoundedCornerShape(4.dp)) | |
) | |
Column { | |
Text( | |
text = userName, style = TextStyle( | |
color = Color.Black, | |
fontWeight = FontWeight.SemiBold, | |
fontFamily = FontFamily(Font(R.font.sf_pro)) | |
), fontSize = 16.sp | |
) | |
Text( | |
text = buildAnnotatedString { | |
withStyle( | |
style = SpanStyle( | |
color = Color(0xFF565656), | |
fontFamily = FontFamily(Font(R.font.sf_pro)), | |
fontWeight = FontWeight.Light | |
) | |
) { | |
append("invited you to an \n") | |
} | |
withStyle( | |
style = SpanStyle( | |
color = Color(0xFF007AFF), | |
fontFamily = FontFamily(Font(R.font.sf_pro)), | |
fontWeight = FontWeight.Normal | |
) | |
) { | |
append("adventure. ") | |
} | |
withStyle( | |
style = SpanStyle( | |
color = Color(0xFFAEAEAE), | |
fontFamily = FontFamily(Font(R.font.sf_pro)), | |
fontWeight = FontWeight.Normal | |
) | |
) { | |
append(date) | |
} | |
}, style = TextStyle( | |
) | |
) | |
} | |
Spacer(modifier = Modifier.weight(1f, true)) | |
TextButton( | |
modifier = Modifier.height(40.dp), | |
shape = RoundedCornerShape(4.dp), | |
colors = ButtonDefaults.textButtonColors( | |
containerColor = Color(0xFF007AFF) | |
), | |
onClick = { | |
notificationsViewModel.sendIntent( | |
NotificationsIntent.ShowBottomSheet | |
) | |
}) { | |
Text( | |
modifier = Modifier | |
.height(16.dp) | |
.padding(horizontal = 15.dp), | |
text = "Going ?", | |
color = Color.White, | |
style = TextStyle( | |
fontFamily = FontFamily(Font(R.font.sf_pro)), | |
fontWeight = FontWeight.SemiBold, | |
fontSize = 12.sp | |
) | |
) | |
} | |
} | |
} | |
//*************************************************************** | |
@Composable | |
fun NotificationMentionItem(imageId: Int, userName: String, date: String) { | |
Row( | |
modifier = Modifier | |
.padding(horizontal = 20.dp, vertical = 10.dp) | |
.fillMaxWidth(), | |
verticalAlignment = Alignment.CenterVertically, | |
horizontalArrangement = Arrangement.spacedBy(5.dp) | |
) { | |
Image( | |
painter = painterResource(id = imageId), | |
contentDescription = "Friend Request Profile Image", | |
modifier = Modifier | |
.size(50.dp) | |
.clip(RoundedCornerShape(4.dp)) | |
) | |
Column { | |
Text( | |
text = userName, style = TextStyle( | |
color = Color.Black, | |
fontWeight = FontWeight.SemiBold, | |
fontFamily = FontFamily(Font(R.font.sf_pro)) | |
), fontSize = 16.sp | |
) | |
Text( | |
text = buildAnnotatedString { | |
withStyle( | |
style = SpanStyle( | |
color = Color(0xFF565656), | |
fontFamily = FontFamily(Font(R.font.sf_pro)), | |
fontWeight = FontWeight.Light | |
) | |
) { | |
append("mentioned you in a ") | |
} | |
withStyle( | |
style = SpanStyle( | |
color = Color(0xFF007AFF), | |
fontFamily = FontFamily(Font(R.font.sf_pro)), | |
fontWeight = FontWeight.Normal | |
) | |
) { | |
append("comment. ") | |
} | |
withStyle( | |
style = SpanStyle( | |
color = Color(0xFFAEAEAE), | |
fontFamily = FontFamily(Font(R.font.sf_pro)), | |
fontWeight = FontWeight.Normal | |
) | |
) { | |
append(date) | |
} | |
}, style = TextStyle( | |
) | |
) | |
} | |
} | |
} | |
/** | |
* نوتیفیکیشنی که از طرف شخصی آمده و درخواست دوستی دارد | |
* */ | |
@Composable | |
fun RequestItem(imageId: Int, userName: String, date: String) { | |
Row( | |
modifier = Modifier | |
.padding(horizontal = 20.dp, vertical = 10.dp) | |
.fillMaxWidth(), | |
verticalAlignment = Alignment.CenterVertically, | |
horizontalArrangement = Arrangement.spacedBy(5.dp) | |
) { | |
Image( | |
painter = rememberAsyncImagePainter(model = imageId), | |
contentDescription = "Friend Request Profile Image", | |
modifier = Modifier | |
.size(50.dp) | |
.clip(RoundedCornerShape(4.dp)) | |
) | |
Column { | |
Text( | |
text = userName, style = TextStyle( | |
color = Color.Black, | |
fontWeight = FontWeight.SemiBold, | |
fontFamily = FontFamily(Font(R.font.sf_pro)) | |
), fontSize = 16.sp | |
) | |
Text( | |
text = buildAnnotatedString { | |
withStyle( | |
style = SpanStyle( | |
color = Color(0xFF565656), | |
fontFamily = FontFamily(Font(R.font.sf_pro)), | |
fontWeight = FontWeight.Light | |
) | |
) { | |
append("has sent a friend \n request. ") | |
} | |
withStyle( | |
style = SpanStyle( | |
color = Color(0xFFAEAEAE), | |
fontFamily = FontFamily(Font(R.font.sf_pro)), | |
fontWeight = FontWeight.Normal | |
) | |
) { | |
append(date) | |
} | |
}, style = TextStyle( | |
) | |
) | |
} | |
Spacer(modifier = Modifier.weight(1f, true)) | |
TextButton( | |
modifier = Modifier.height(32.5.dp), | |
shape = RoundedCornerShape(4.dp), | |
colors = ButtonDefaults.textButtonColors( | |
containerColor = Color(0xFF30D158) | |
), | |
onClick = { }) { | |
Text( | |
modifier = Modifier | |
.height(16.dp) | |
.padding(horizontal = 10.dp), | |
text = "Accept", | |
color = Color.White, | |
style = TextStyle( | |
fontFamily = FontFamily(Font(R.font.sf_pro)), | |
fontWeight = FontWeight.SemiBold, | |
fontSize = 16.sp | |
) | |
) | |
} | |
TextButton( | |
modifier = Modifier.height(32.5.dp), | |
shape = RoundedCornerShape(4.dp), | |
colors = ButtonDefaults.textButtonColors( | |
containerColor = Color(0xffF2F2F7) | |
), | |
onClick = { }) { | |
Text( | |
modifier = Modifier | |
.height(16.dp) | |
.padding(horizontal = 10.dp), | |
text = "Decline", | |
color = Color.Black, | |
style = TextStyle( | |
fontFamily = FontFamily(Font(R.font.sf_pro)), | |
fontWeight = FontWeight.SemiBold, | |
fontSize = 16.sp | |
) | |
) | |
} | |
} | |
} | |
@Composable | |
fun TimeCategoryDivider() { | |
Box( | |
modifier = Modifier.padding(vertical = 5.dp) | |
) { | |
HorizontalDivider( | |
color = Color(0xFFF2F2F7), | |
modifier = Modifier | |
.align(Alignment.Center) | |
.background(Color(0xFFF2F2F7)) | |
.fillMaxWidth() | |
.height(0.25.dp) | |
) | |
} | |
} | |
@Composable | |
fun NotificationTimeCategory(text: String) { | |
Text( | |
text = text, | |
modifier = Modifier.padding(start = 20.dp, top = 10.dp, end = 20.dp), | |
style = TextStyle( | |
color = Color(0xff1C1C1E), | |
fontWeight = FontWeight.SemiBold, | |
fontFamily = FontFamily(Font(R.font.sf_pro)) | |
), | |
fontSize = 20.sp | |
) | |
} | |
@Composable | |
fun NotificationAdventureItem( | |
imageId: Int, eventName: String, eventDescription: String, time: String | |
) { | |
Box( | |
modifier = Modifier | |
.height(70.dp) | |
.padding(10.dp), contentAlignment = Alignment.Center | |
) { | |
Row( | |
modifier = Modifier.align(Alignment.Center), | |
verticalAlignment = Alignment.CenterVertically | |
) { | |
Box( | |
modifier = Modifier.wrapContentHeight(), contentAlignment = Alignment.Center | |
) { | |
Image( | |
painter = painterResource(id = imageId), | |
contentDescription = "", | |
modifier = Modifier | |
.size(76.dp) | |
.align(Alignment.Center) | |
.aspectRatio(1f) // Ensures square aspect ratio | |
.padding(10.dp) | |
.clip(RoundedCornerShape(8.dp)), | |
contentScale = ContentScale.FillBounds | |
) | |
} | |
Box { | |
Column( | |
modifier = Modifier.fillMaxHeight(), | |
verticalArrangement = Arrangement.SpaceEvenly | |
) { | |
Text( | |
modifier = Modifier.padding(horizontal = 10.dp), | |
text = eventName, | |
style = TextStyle( | |
color = Color.Black, fontSize = 18.sp, fontWeight = FontWeight.SemiBold | |
), | |
fontFamily = FontFamily(Font(R.font.sf_pro)) | |
) | |
Text( | |
modifier = Modifier.padding(horizontal = 10.dp), | |
text = buildAnnotatedString { | |
withStyle(SpanStyle(color = Color(0xFF565656))) { | |
append(eventDescription) | |
} | |
} + buildAnnotatedString { | |
withStyle(SpanStyle(color = Color(0xFFAEAEAE))) { | |
append(time) | |
} | |
}, | |
style = TextStyle( | |
fontSize = 16.sp, | |
color = Color(0xFFA3A3A3), | |
fontFamily = FontFamily(Font(R.font.sf_pro)) | |
) | |
) | |
} | |
} | |
Spacer(modifier = Modifier.weight(1f)) | |
} | |
} | |
} | |
@ExperimentalMaterial3Api | |
@Composable | |
fun GeneralBottomSheet( | |
modifier: Modifier = Modifier, | |
showBottomSheet: Boolean, | |
onDismissRequest: () -> Unit, | |
content: @Composable () -> Unit // Accept composable content | |
) { | |
if (showBottomSheet) { | |
ModalBottomSheet( | |
sheetState = rememberModalBottomSheetState(), | |
modifier = modifier.wrapContentHeight(), | |
containerColor = Color.Transparent, | |
dragHandle = { Box { } }, | |
onDismissRequest = onDismissRequest, | |
content = { | |
Box( | |
modifier = Modifier | |
.padding(horizontal = 20.dp, vertical = 80.dp) | |
.fillMaxSize() | |
.background(Color.Transparent) | |
) { | |
content() // Pass the composable content here | |
} | |
}, | |
scrimColor = Color(0x82000000) // Optional: Customize scrim color | |
) | |
} | |
} | |
@Composable | |
fun BottomSheetContent( | |
options: List<String>, | |
onOptionClick: (String) -> Unit, | |
onCancelClick: () -> Unit, | |
optionStyles: List<TextStyle> = emptyList() | |
) { | |
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { | |
ResponseOptionsCard( | |
options = options, | |
optionStyles = optionStyles, | |
onOptionClick = onOptionClick | |
) | |
CancelOptionCard( | |
onCancelClick = onCancelClick | |
) | |
} | |
} | |
@Composable | |
fun ResponseOptionsCard( | |
options: List<String>, | |
optionStyles: List<TextStyle> = emptyList(), | |
onOptionClick: (String) -> Unit | |
) { | |
Card( | |
colors = CardDefaults.cardColors(containerColor = Color.White), | |
modifier = Modifier | |
.fillMaxWidth() | |
.height((70 * options.size).dp), | |
shape = RoundedCornerShape(14.dp) | |
) { | |
Column { | |
options.forEachIndexed { index, option -> | |
Box( | |
modifier = Modifier | |
.weight(1f) | |
.fillMaxWidth() | |
.clickable { onOptionClick(option) }, | |
) { | |
Text( | |
modifier = Modifier.align(Alignment.Center), | |
text = option, | |
style = optionStyles.getOrNull(index) | |
?: TextStyle( // Check if a style is provided | |
fontSize = 20.sp, | |
fontWeight = FontWeight.SemiBold, | |
color = Color(0xFF007AFF) | |
) | |
) | |
// Divider except for the last option | |
if (index < options.size) { | |
BottomSheetDivider() | |
} | |
} | |
} | |
} | |
} | |
} | |
@Composable | |
fun BottomSheetDivider() { | |
HorizontalDivider( | |
color = Color(0x82000000).copy(alpha = 0.51f), | |
modifier = Modifier | |
.height(1.dp) | |
.fillMaxWidth() | |
) | |
} | |
@Composable | |
fun CancelOptionCard( | |
onCancelClick: () -> Unit | |
) { | |
Card( | |
modifier = Modifier | |
.fillMaxWidth() | |
.height(70.dp) | |
.clickable { onCancelClick() }, | |
shape = RoundedCornerShape(14.dp), | |
colors = CardDefaults.cardColors(containerColor = Color.White) | |
) { | |
Box( | |
modifier = Modifier.fillMaxSize(), | |
contentAlignment = Alignment.Center | |
) { | |
Text( | |
text = "Cancel", | |
style = TextStyle( | |
fontSize = 20.sp, | |
color = Color(0xFFFF3B30) | |
) | |
) | |
} | |
} | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\ui\screens\main\home\notifications\search\filter\Filter.kt | |
```kt | |
package com.divadventure.ui.screens.main.home.notifications.search.filter | |
import androidx.compose.foundation.Image | |
import androidx.compose.foundation.background | |
import androidx.compose.foundation.clickable | |
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.fillMaxSize | |
import androidx.compose.foundation.layout.fillMaxWidth | |
import androidx.compose.foundation.layout.padding | |
import androidx.compose.foundation.layout.size | |
import androidx.compose.material3.Card | |
import androidx.compose.material3.CardDefaults | |
import androidx.compose.material3.Text | |
import androidx.compose.runtime.Composable | |
import androidx.compose.runtime.LaunchedEffect | |
import androidx.compose.runtime.collectAsState | |
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.graphics.Color | |
import androidx.compose.ui.graphics.vector.ImageVector | |
import androidx.compose.ui.res.painterResource | |
import androidx.compose.ui.res.vectorResource | |
import androidx.compose.ui.text.TextStyle | |
import androidx.compose.ui.unit.dp | |
import androidx.compose.ui.unit.sp | |
import com.divadventure.R | |
import com.divadventure.data.navigation.NavigationEvent | |
import com.divadventure.data.navigation.NavigationViewModel | |
import com.divadventure.ui.ApplyButton | |
import com.divadventure.ui.BackCompose | |
import com.divadventure.ui.SelectDate | |
import com.divadventure.viewmodel.HomeIntent | |
import com.divadventure.viewmodel.HomeUiEvent | |
import com.divadventure.viewmodel.HomeViewModel | |
import com.divadventure.viewmodel.MainIntent | |
import com.divadventure.viewmodel.MainUiEvent | |
import com.divadventure.viewmodel.MainViewModel | |
import com.kizitonwose.calendar.core.CalendarDay | |
@Composable | |
fun Filter( | |
mainViewModel: MainViewModel, | |
navigationViewModel: NavigationViewModel, | |
homeViewModel: HomeViewModel, | |
paddingValues: PaddingValues | |
) { | |
var state = homeViewModel.state.collectAsState().value | |
var showStartDateDialog by remember { mutableStateOf(false) } | |
var showEndDateDialog by remember { mutableStateOf(false) } | |
LaunchedEffect(key1 = true) { | |
homeViewModel.uiEvent.collect { event -> | |
when (event) { | |
is HomeUiEvent.ShowDim -> {} | |
is HomeUiEvent.ShowSnackbar -> {} | |
HomeUiEvent.AnimateItem -> {} | |
is HomeUiEvent.NavigateToNextScreen -> { | |
} | |
HomeUiEvent.ShowStartDateDialog -> { | |
showStartDateDialog = true | |
} | |
HomeUiEvent.ShowEndDateDialog -> { | |
showEndDateDialog = true | |
} | |
else -> { | |
// do nothing | |
} | |
} | |
} | |
} | |
LaunchedEffect(key1 = true) { | |
mainViewModel.uiEvent.collect { event -> | |
when (event) { | |
MainUiEvent.AnimateItem -> { | |
} | |
is MainUiEvent.NavigateToNextScreen -> { | |
navigationViewModel.navigate(event.navigationEvent) | |
} | |
MainUiEvent.ShowDialog -> { | |
} | |
is MainUiEvent.ShowDim -> { | |
} | |
is MainUiEvent.ShowSnackbar -> { | |
} | |
is MainUiEvent.AdventureAction -> { | |
} | |
} | |
} | |
} | |
if (showStartDateDialog) { | |
SelectDate( | |
onDismissRequest = { showStartDateDialog = false }, | |
onSelectDate = { date, cloc, amPm -> | |
if (date != null) { | |
val formattedDate = calendarDayToString(date) | |
// Use formattedDate if needed | |
homeViewModel.sendIntent(HomeIntent.SetStartDate(formattedDate)) | |
} | |
showStartDateDialog = false | |
}) | |
} else if (showEndDateDialog) { | |
SelectDate( | |
onDismissRequest = { showEndDateDialog = false }, | |
onSelectDate = { date, clock, amPm -> | |
if (date != null) { | |
val formattedDate = calendarDayToString(date) | |
// Use formattedDate if needed | |
homeViewModel.sendIntent(HomeIntent.SetEndDate(formattedDate)) | |
} | |
showEndDateDialog = false | |
}) | |
} | |
Box(modifier = Modifier.background(Color.White)) { | |
Box( | |
modifier = Modifier | |
.padding(paddingValues) | |
.fillMaxSize() | |
.background(Color(0xFFF2F2F7)) | |
) { | |
Column { | |
BackCompose("Filter", modifier = Modifier) { | |
navigationViewModel.navigate(NavigationEvent.PopBackStack) | |
} | |
Column( | |
modifier = Modifier.padding(top = 10.dp), | |
verticalArrangement = Arrangement.spacedBy(10.dp) | |
) { | |
FilterItem( | |
R.drawable.ic_heart, | |
"Interests", | |
state.filters.interests?.joinToString(", ") { it.name } ?: "") { | |
mainViewModel.sendIntent(MainIntent.GotoInterests) | |
} | |
FilterItem( | |
R.drawable.ic_location, "Location", state.newLocation?.name ?: "" | |
) { | |
mainViewModel.sendIntent(MainIntent.GotoLocation) | |
} | |
FilterItem( | |
R.drawable.ic_calendar, "Start Date", state.filters.startDate ?: "" | |
) { | |
homeViewModel.sendIntent(HomeIntent.ShowStartDateDialog) | |
} | |
FilterItem( | |
R.drawable.ic_calendar, "End Date", state.filters.endDate ?: "" | |
) { | |
homeViewModel.sendIntent(HomeIntent.ShowEndDateDialog) | |
} | |
FilterItem(R.drawable.ic_flag, "Status", state.filters.state ?: "", { | |
mainViewModel.sendIntent(MainIntent.GotoStatus) | |
}) | |
ApplyButton { | |
homeViewModel.sendIntent(HomeIntent.ApplyFilter) | |
navigationViewModel.navigate(NavigationEvent.PopBackStack) | |
} | |
} | |
} | |
} | |
} | |
} | |
@Composable | |
fun FilterItem(imageId: Int, title: String, selectionText: String, onClick: () -> Unit) { | |
Card( | |
modifier = Modifier | |
.fillMaxWidth() | |
.padding(horizontal = 20.dp) | |
.clickable { | |
onClick() | |
}, colors = CardDefaults.cardColors( | |
containerColor = Color.White | |
) | |
) { | |
Row( | |
modifier = Modifier.padding(vertical = 5.dp), | |
verticalAlignment = Alignment.CenterVertically | |
) { | |
Image( | |
colorFilter = androidx.compose.ui.graphics.ColorFilter.tint(Color(0xFF848484)), | |
painter = painterResource(id = imageId), | |
contentDescription = title, | |
modifier = Modifier | |
.padding(10.dp) | |
.size(25.dp) | |
) | |
Text( | |
text = title, | |
modifier = Modifier.padding(vertical = 10.dp), | |
style = TextStyle(color = Color(0xFF1C1C1E)) | |
) | |
Text( | |
modifier = Modifier | |
.weight(1f, true) | |
.align(Alignment.CenterVertically), | |
text = selectionText, | |
color = Color(0xff848484), | |
style = TextStyle( | |
textAlign = androidx.compose.ui.text.style.TextAlign.End, fontSize = 14.sp | |
) | |
) | |
Image( | |
imageVector = ImageVector.vectorResource(R.drawable.right_chevron), | |
contentDescription = title, | |
modifier = Modifier.padding(10.dp) | |
) | |
} | |
} | |
} | |
fun calendarDayToString(calendarDay: CalendarDay): String { | |
return calendarDay.date.year.toString() + "-" + calendarDay.date.monthValue.toString() | |
.toString() + "-" + calendarDay.date.dayOfMonth.toString() | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\ui\screens\main\home\notifications\search\filter\Interests.kt | |
```kt | |
package com.divadventure.ui.screens.main.home.notifications.search.filter | |
import androidx.compose.animation.AnimatedVisibility | |
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.layout.Box | |
import androidx.compose.foundation.layout.Column | |
import androidx.compose.foundation.layout.PaddingValues | |
import androidx.compose.foundation.layout.fillMaxHeight | |
import androidx.compose.foundation.layout.fillMaxSize | |
import androidx.compose.foundation.layout.fillMaxWidth | |
import androidx.compose.foundation.layout.padding | |
import androidx.compose.foundation.layout.size | |
import androidx.compose.foundation.lazy.LazyColumn | |
import androidx.compose.foundation.lazy.itemsIndexed | |
import androidx.compose.runtime.Composable | |
import androidx.compose.runtime.collectAsState | |
import androidx.compose.runtime.getValue | |
import androidx.compose.runtime.mutableStateOf | |
import androidx.compose.runtime.remember | |
import androidx.compose.runtime.setValue | |
import androidx.compose.runtime.toMutableStateList | |
import androidx.compose.ui.Alignment | |
import androidx.compose.ui.Modifier | |
import androidx.compose.ui.graphics.Color | |
import androidx.compose.ui.res.painterResource | |
import androidx.compose.ui.unit.dp | |
import com.divadventure.R | |
import com.divadventure.data.navigation.NavigationEvent | |
import com.divadventure.data.navigation.NavigationViewModel | |
import com.divadventure.domain.models.Interest | |
import com.divadventure.ui.ApplyButton | |
import com.divadventure.ui.HeaderWithCloseButton | |
import com.divadventure.ui.SearchField | |
import com.divadventure.ui.SelectionRow | |
import com.divadventure.ui.SortDivider | |
import com.divadventure.ui.screens.Loader | |
import com.divadventure.viewmodel.AdventuresIntent | |
import com.divadventure.viewmodel.HomeIntent | |
import com.divadventure.viewmodel.HomeViewModel | |
import com.divadventure.viewmodel.ManageAdventureViewModel | |
@Composable | |
fun AdventureInterests( | |
navigationViewModel: NavigationViewModel, | |
adventureViewModel: ManageAdventureViewModel, | |
paddingValues: PaddingValues | |
) { | |
// Move all homeViewModel interactions to the top level | |
val state = adventureViewModel.state.collectAsState() | |
val allInterests = state.value.allInterests.sortedBy { it.name } | |
var querySearch by remember { mutableStateOf("") } | |
var selectedItems = remember { | |
state.value.adventureInterests?.toMutableStateList() | |
?: emptyList<Interest>().toMutableStateList() | |
} | |
if (allInterests.isEmpty()) { | |
adventureViewModel.sendIntent(AdventuresIntent.FetchInterests) | |
} | |
// Define callbacks that will be passed down | |
val onApplyInterests = { | |
adventureViewModel.sendIntent(AdventuresIntent.ApplyInterests(selectedItems.toMutableList())) | |
navigationViewModel.navigate(NavigationEvent.PopBackStack) | |
} | |
InterestsScreenLayout( | |
paddingValues = paddingValues, | |
isLoading = allInterests.isNotEmpty(), | |
hasInterests = allInterests.isNotEmpty(), | |
onCloseClick = { | |
navigationViewModel.navigate(NavigationEvent.PopBackStack) | |
}, | |
searchContent = { | |
SearchField(queryText = querySearch) { | |
querySearch = it | |
} | |
}, | |
listContent = { | |
SelectionList( | |
allInterests = allInterests, | |
selectedItems = selectedItems, | |
querySearch = querySearch | |
) | |
}, | |
applyButtonContent = { | |
ApplyButton("Apply") { | |
onApplyInterests() | |
} | |
}) | |
} | |
@Composable | |
fun FilterInterests( | |
navigationViewModel: NavigationViewModel, | |
homeViewModel: HomeViewModel, | |
paddingValues: PaddingValues | |
) { | |
// Move all homeViewModel interactions to the top level | |
val state = homeViewModel.state.collectAsState() | |
val allInterests = state.value.allInterests.sortedBy { it.name } | |
val isInterestsEmpty = state.value.allInterests.isEmpty() | |
var querySearch by remember { mutableStateOf("") } | |
var selectedItems = | |
remember { | |
state.value.filters.interests?.toMutableStateList() | |
?: emptyList<Interest>().toMutableStateList() | |
} | |
if (allInterests.isEmpty()) { | |
homeViewModel.sendIntent(HomeIntent.FetchInterests) | |
} | |
// Define callbacks that will be passed down | |
val onApplyInterests = { | |
homeViewModel.sendIntent(HomeIntent.ApplyInterests(selectedItems.toMutableList())) | |
navigationViewModel.navigate(NavigationEvent.PopBackStack) | |
} | |
InterestsScreenLayout( | |
paddingValues = paddingValues, | |
isLoading = allInterests.isNotEmpty(), | |
hasInterests = allInterests.isNotEmpty(), | |
onCloseClick = { | |
navigationViewModel.navigate(NavigationEvent.PopBackStack) | |
}, | |
searchContent = { | |
SearchField(queryText = querySearch) { | |
querySearch = it | |
} | |
}, | |
listContent = { | |
SelectionList( | |
allInterests = allInterests, | |
selectedItems = selectedItems, | |
querySearch = querySearch | |
) | |
}, | |
applyButtonContent = { | |
ApplyButton("Apply") { | |
onApplyInterests() | |
} | |
}) | |
} | |
@Composable | |
fun InterestsScreenLayout( | |
paddingValues: PaddingValues, | |
isLoading: Boolean, | |
hasInterests: Boolean, | |
onCloseClick: () -> Unit, | |
searchContent: @Composable () -> Unit, | |
listContent: @Composable () -> Unit, | |
applyButtonContent: @Composable () -> Unit | |
) { | |
Box( | |
modifier = Modifier | |
.background(Color.White) | |
.fillMaxSize() | |
.padding(paddingValues) | |
.background(Color(0xFFEFEFF4)) | |
) { | |
AnimatedVisibility(isLoading) { | |
Loader( | |
modifier = Modifier | |
.fillMaxSize() | |
.padding(paddingValues) | |
) | |
} | |
Column( | |
modifier = Modifier | |
.fillMaxWidth() | |
.background(color = Color.White) | |
.padding(0.dp) | |
) { | |
HeaderWithCloseButton(title = "Interests") { | |
onCloseClick() | |
} | |
AnimatedVisibility(hasInterests) { | |
Column { | |
Box(modifier = Modifier.padding(bottom = 15.dp)) { | |
searchContent() | |
} | |
} | |
} | |
listContent() | |
AnimatedVisibility(hasInterests) { | |
Box( | |
modifier = Modifier | |
.fillMaxWidth() | |
.background(color = Color(0xFFEFEFF4)) | |
) { | |
applyButtonContent() | |
} | |
} | |
} | |
} | |
} | |
@Composable | |
fun SelectionImage(isSelected: Boolean) { | |
Box( | |
modifier = Modifier.size(40.dp), contentAlignment = Alignment.Center | |
) { | |
// Show AnimatedVisibility with smooth fade-in and scale-in animation when visible | |
AnimatedVisibility( | |
visible = isSelected, | |
enter = fadeIn(animationSpec = tween(durationMillis = 300)) + scaleIn( | |
initialScale = 0.8f, animationSpec = tween(durationMillis = 300) | |
), | |
exit = fadeOut(animationSpec = tween(durationMillis = 300)) + scaleOut( | |
targetScale = 0.8f, animationSpec = tween(durationMillis = 300) | |
) | |
) { | |
Image( | |
painter = painterResource(id = R.drawable.ic_circle_selected), | |
contentDescription = "Selected Sort", | |
modifier = Modifier.align(Alignment.Center) | |
) | |
} | |
// Show AnimatedVisibility for the unselected state | |
AnimatedVisibility( | |
visible = !isSelected, | |
enter = fadeIn(animationSpec = tween(durationMillis = 300)) + scaleIn( | |
initialScale = 0.8f, animationSpec = tween(durationMillis = 300) | |
), | |
exit = fadeOut(animationSpec = tween(durationMillis = 300)) + scaleOut( | |
targetScale = 0.8f, animationSpec = tween(durationMillis = 300) | |
) | |
) { | |
Image( | |
painter = painterResource(id = R.drawable.ic_circle_unselected), | |
contentDescription = "Not Selected", | |
modifier = Modifier.align(Alignment.Center) | |
) | |
} | |
} | |
} | |
@Composable | |
fun SelectionList( | |
allInterests: List<Interest>, | |
selectedItems: MutableList<Interest>, | |
querySearch: String | |
) { | |
val filteredItems = remember(allInterests, selectedItems, querySearch) { | |
val unselectedItems = allInterests | |
.filterNot { selectedItems.contains(it) } | |
.map { it.name } | |
.filter { it.startsWith(querySearch, ignoreCase = true) } | |
val selectedItemNames = selectedItems.map { it.name } | |
unselectedItems + selectedItemNames | |
} | |
AnimatedVisibility(visible = allInterests.isNotEmpty()) { | |
LazyColumn( | |
modifier = Modifier | |
.fillMaxWidth() | |
.fillMaxHeight(0.45f) | |
) { | |
itemsIndexed(filteredItems) { _, item -> | |
SelectionRowWithDivider( | |
text = item, | |
isSelected = selectedItems.any { it.name == item }, | |
onItemClick = { handleSelectionClick(item, allInterests, selectedItems) } | |
) | |
} | |
} | |
} | |
} | |
@Composable | |
fun SelectionRowWithDivider( | |
text: String, | |
isSelected: Boolean, | |
onItemClick: () -> Unit | |
) { | |
SelectionRow( | |
text = text, | |
isSelected = isSelected, | |
selectionImage = { isSelected -> SelectionImage(isSelected) }, | |
onClick = onItemClick | |
) | |
SortDivider() | |
} | |
private fun handleSelectionClick( | |
item: String, | |
allInterests: List<Interest>, | |
selectedItems: MutableList<Interest> | |
) { | |
if (selectedItems.any { it.name == item }) { | |
selectedItems.removeIf { it.name == item } | |
} else { | |
allInterests.firstOrNull { it.name == item }?.let { selectedItems.add(it) } | |
} | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\ui\screens\main\home\notifications\search\filter\Location.kt | |
```kt | |
package com.divadventure.ui.screens.main.home.notifications.search.filter | |
import androidx.compose.animation.AnimatedVisibility | |
import androidx.compose.animation.expandVertically | |
import androidx.compose.animation.fadeIn | |
import androidx.compose.animation.fadeOut | |
import androidx.compose.animation.shrinkVertically | |
import androidx.compose.foundation.Image | |
import androidx.compose.foundation.background | |
import androidx.compose.foundation.clickable | |
import androidx.compose.foundation.gestures.awaitEachGesture | |
import androidx.compose.foundation.gestures.awaitFirstDown | |
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.aspectRatio | |
import androidx.compose.foundation.layout.fillMaxSize | |
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.wrapContentHeight | |
import androidx.compose.foundation.lazy.LazyColumn | |
import androidx.compose.foundation.lazy.itemsIndexed | |
import androidx.compose.foundation.shape.RoundedCornerShape | |
import androidx.compose.material3.Card | |
import androidx.compose.material3.CardDefaults | |
import androidx.compose.material3.Text | |
import androidx.compose.material3.TextField | |
import androidx.compose.material3.TextFieldDefaults | |
import androidx.compose.runtime.Composable | |
import androidx.compose.runtime.LaunchedEffect | |
import androidx.compose.runtime.MutableState | |
import androidx.compose.runtime.collectAsState | |
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.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.vector.ImageVector | |
import androidx.compose.ui.input.pointer.pointerInput | |
import androidx.compose.ui.res.vectorResource | |
import androidx.compose.ui.text.SpanStyle | |
import androidx.compose.ui.text.TextStyle | |
import androidx.compose.ui.text.buildAnnotatedString | |
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.withStyle | |
import androidx.compose.ui.unit.dp | |
import androidx.compose.ui.unit.sp | |
import com.divadventure.R | |
import com.divadventure.data.navigation.NavigationEvent | |
import com.divadventure.data.navigation.NavigationViewModel | |
import com.divadventure.ui.HeaderWithCloseButton | |
import com.divadventure.ui.screens.Loader | |
import com.divadventure.viewmodel.HomeIntent | |
import com.divadventure.viewmodel.HomeViewModel | |
import com.divadventure.viewmodel.MainViewModel | |
import com.google.android.gms.maps.CameraUpdateFactory | |
import com.google.android.gms.maps.model.CameraPosition | |
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.maps.android.compose.CameraPositionState | |
import com.google.maps.android.compose.GoogleMap | |
import com.google.maps.android.compose.MapUiSettings | |
import com.google.maps.android.compose.Marker | |
import com.google.maps.android.compose.rememberMarkerState | |
import kotlinx.coroutines.launch | |
import java.util.Locale | |
@Composable | |
fun Location( | |
mainViewModel: MainViewModel, | |
navigationViewModel: NavigationViewModel, | |
homeViewModel: HomeViewModel, | |
paddingValues: PaddingValues | |
) { | |
var locationScreenSearchText by remember { mutableStateOf("") } | |
Box( | |
modifier = Modifier | |
.fillMaxSize() | |
.background(Color.White) | |
.padding(paddingValues) | |
.background(Color(0xFFEFEFF4)) | |
) { | |
val homeState = homeViewModel.state.collectAsState().value | |
GoogleMapWithLocationSearch( | |
searchFieldValue = locationScreenSearchText, | |
onSearchFieldValueChange = { locationScreenSearchText = it }, | |
locationsPredicted = homeState.locationsPredicted, | |
newLocation = homeState.newLocation, | |
isLocationLoading = homeState.isLoading.locationIsLoading, | |
onLocationFieldChanged = { query -> | |
homeViewModel.sendIntent(HomeIntent.LocationFieldChanged(query)) | |
}, | |
onLocationSelected = { selectedLocation -> | |
// locationScreenSearchText is updated by GoogleMapWithLocationSearch's onLocationSelected | |
homeViewModel.sendIntent(HomeIntent.LocationSelected(selectedLocation)) | |
}, | |
onBackPressed = { | |
navigationViewModel.navigate(NavigationEvent.PopBackStack) | |
}, | |
onMapClicked = { latLng -> | |
locationScreenSearchText = "${latLng.latitude}, ${latLng.longitude}" | |
homeViewModel.sendIntent(HomeIntent.MapClicked(latLng)) | |
}, | |
) | |
} | |
} | |
data class LocationSearchCallbacks( | |
val onLocationFieldChanged: (String) -> Unit, | |
val onLocationSelected: (AutocompletePrediction) -> Unit, | |
val onBackPressed: (() -> Unit)? = null | |
) | |
data class LocationSearchData( | |
val locationsPredicted: MutableList<AutocompletePrediction>, | |
val newLocation: Place?, | |
val isLocationLoading: Boolean | |
) | |
@Composable | |
fun GoogleMapWithLocationSearch( | |
modifier: Modifier = Modifier, | |
searchFieldValue: String, | |
onSearchFieldValueChange: (String) -> Unit, | |
locationsPredicted: MutableList<AutocompletePrediction>, | |
newLocation: Place?, | |
isLocationLoading: Boolean, | |
onLocationFieldChanged: (String) -> Unit, | |
onLocationSelected: (AutocompletePrediction) -> Unit, | |
onBackPressed: (() -> Unit)? = null, | |
showHeader: Boolean = true, | |
headerTitle: String = "Location", | |
isMapMoving: MutableState<Boolean> = mutableStateOf(false), | |
onMapClicked: (LatLng) -> Unit, | |
) { | |
var isDropdownVisible = remember { mutableStateOf(false) } // Initialize to false | |
// var isLocationFound = remember { mutableStateOf(false) } // Seems unused, consider removing | |
LaunchedEffect(locationsPredicted) { | |
isDropdownVisible.value = locationsPredicted.isNotEmpty() | |
} | |
Box( | |
modifier = modifier.fillMaxSize() | |
) { | |
// Optional Header | |
if (showHeader) { | |
HeaderWithCloseButton( | |
modifier = Modifier, | |
title = headerTitle | |
) { | |
onBackPressed?.invoke() | |
} | |
} | |
// Map Section | |
// Map Section - clipped to its bounds | |
Box( | |
modifier = Modifier | |
.fillMaxSize() | |
.pointerInput(Unit) { | |
awaitPointerEventScope { | |
while (true) { | |
awaitPointerEvent() // Consume all pointer input — disables scroll for parent | |
} | |
} | |
}/*.pointerInteropFilter { | |
// Always consume touch events so they don't propagate to the Column | |
true | |
}*/ | |
) { | |
GoogleMapScreen( | |
modifier = Modifier | |
.padding(top = if (showHeader) 0.dp else 120.dp) | |
.fillMaxSize() | |
.aspectRatio(1f), | |
isMapMoving, | |
newLocation = newLocation, | |
// textState = textState, // No longer needed here | |
items = locationsPredicted, // This might also be redundant if GoogleMapScreen doesn't use it directly | |
onMapLatLngClicked = onMapClicked | |
) | |
} | |
// Location Input Section | |
Column( | |
verticalArrangement = Arrangement.spacedBy(10.dp), | |
modifier = Modifier | |
.fillMaxWidth() | |
.padding( | |
start = 20.dp, | |
end = 20.dp, | |
bottom = 30.dp, | |
top = if (showHeader) 80.dp else 0.dp | |
) | |
) { | |
if (showHeader) Row( | |
verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth() | |
) { | |
Text("Location", style = TextStyle(fontSize = 14.sp, color = Color(0xFF1C1C1E))) | |
Image( | |
imageVector = ImageVector.vectorResource(R.drawable.ic_asterisk), | |
contentDescription = "Location Image", | |
colorFilter = ColorFilter.tint(Color.Red), | |
modifier = Modifier | |
.padding(start = 0.dp) | |
.size(7.5.dp) | |
) | |
} | |
Column { | |
TextField( | |
singleLine = true, | |
value = searchFieldValue, | |
onValueChange = { newValue -> | |
onSearchFieldValueChange(newValue) | |
if (newValue.isNotEmpty()) { | |
onLocationFieldChanged(newValue) | |
} | |
}, | |
modifier = Modifier | |
.fillMaxWidth() | |
.clip( | |
RoundedCornerShape(10.dp) | |
) | |
.background(Color.White), | |
shape = RoundedCornerShape(10.dp), | |
colors = TextFieldDefaults.colors( | |
focusedContainerColor = Color.White, | |
unfocusedContainerColor = Color.White, | |
focusedIndicatorColor = Color.Transparent, | |
unfocusedIndicatorColor = Color.Transparent | |
), | |
textStyle = TextStyle( | |
fontFamily = FontFamily(Font(R.font.sf_pro)), | |
color = Color(0xFF1C1C1E) | |
), | |
leadingIcon = { | |
Box(modifier = Modifier.padding(5.dp)) { | |
Text( | |
text = "to", style = TextStyle( | |
fontSize = 18.sp, color = Color(0xFF1C1C1E) | |
) | |
) | |
} | |
}, | |
trailingIcon = { | |
AnimatedVisibility(isLocationLoading) { | |
Loader(modifier = Modifier.size(50.dp)) | |
} | |
} | |
) | |
/* var dropdownOffsetY = remember { mutableStateOf(0f) } | |
HorizontalDivider( | |
modifier = Modifier | |
.fillMaxWidth() | |
.height(1.dp) | |
.background(Color(0xFFB7B7B7)) | |
.onGloballyPositioned { coordinates -> | |
dropdownOffsetY.value = coordinates.positionInWindow().y | |
} | |
)*/ | |
isDropdownVisible.value = locationsPredicted.isNotEmpty() | |
DropdownAlwaysDropDown( | |
modifier = Modifier | |
.fillMaxWidth() | |
.wrapContentHeight(), | |
isDropdownVisible = isDropdownVisible, | |
items = locationsPredicted, | |
currentSearchText = searchFieldValue, | |
onLocationSelected = { selectedLocation -> | |
isDropdownVisible.value = false // Hide dropdown first | |
onSearchFieldValueChange(selectedLocation.getPrimaryText(null).toString()) // Update search text field | |
onLocationSelected(selectedLocation) // Propagate the selection to the parent | |
} | |
) | |
} | |
} | |
} | |
} | |
@Composable | |
fun MapItem( | |
item: AutocompletePrediction, | |
startHint: String, // Changed from MutableState<String> to String | |
onclick: () -> Unit = {} | |
) { | |
Box( | |
modifier = Modifier | |
.fillMaxWidth() | |
.clickable { | |
onclick() | |
} | |
.padding(10.dp)) { | |
val annotatedText = buildAnnotatedString { | |
// Use startHint directly | |
if (item.getPrimaryText(null).toString().startsWith(startHint, true)) { | |
// Make the startHint part semi-bold | |
withStyle( | |
style = SpanStyle( | |
fontWeight = FontWeight.SemiBold | |
) | |
) { | |
append(startHint) | |
} | |
// Append the rest of the text in normal style | |
append( | |
item.getPrimaryText(null).toString().lowercase(Locale.getDefault()) | |
.removePrefix(startHint.lowercase(Locale.getDefault())) | |
) | |
} else { | |
// No style change, just display the text as is | |
append(item.getPrimaryText(null).toString()) | |
} | |
} | |
Text( | |
text = annotatedText, style = TextStyle(color = Color(0xFF1C1C1E), fontSize = 18.sp) | |
) | |
} | |
} | |
@Composable | |
fun DropdownAlwaysDropDown( | |
modifier: Modifier = Modifier, | |
isDropdownVisible: MutableState<Boolean>, | |
items: MutableList<AutocompletePrediction>, | |
currentSearchText: String, // Changed from textState | |
onLocationSelected: (AutocompletePrediction) -> Unit | |
) { | |
AnimatedVisibility( | |
visible = isDropdownVisible.value, | |
enter = fadeIn() + expandVertically(), // Fade-in and expand from the top | |
exit = fadeOut() + shrinkVertically() // Fade-out and shrink to the top | |
) { | |
// DropdownMenu positioned explicitly downward | |
Card( | |
colors = CardDefaults.cardColors( | |
containerColor = Color.White | |
), shape = RoundedCornerShape(bottomEnd = 10.dp, bottomStart = 10.dp) | |
) { | |
if (items.isNotEmpty()) { | |
LazyColumn(modifier = Modifier.height((50 * items.size).dp)) { | |
itemsIndexed(items) { index, item -> | |
MapItem(item = item, startHint = currentSearchText, onclick = { | |
// isDropdownVisible.value = false // Removed from here | |
onLocationSelected(item) // This now calls the lambda defined in GoogleMapWithLocationSearch | |
}) | |
} | |
} | |
} | |
// Dropdown Items | |
} | |
} | |
} | |
@Composable | |
fun GoogleMapScreen( | |
modifier: Modifier, | |
isMapMoving: MutableState<Boolean>, | |
newLocation: Place?, | |
// textState: MutableState<String>, // Removed | |
items: MutableList<AutocompletePrediction>, // This seems for map markers, not directly for search text | |
onMapLatLngClicked: (LatLng) -> Unit | |
) { | |
// Define a default location (latitude and longitude) | |
val defaultLocation = LatLng(37.7749, -122.4194) // San Francisco, for example | |
val coroutineScope = rememberCoroutineScope() | |
// Create the camera position state and initially center it on the default location | |
val cameraPositionState = remember { | |
CameraPositionState( | |
position = CameraPosition.fromLatLngZoom( | |
defaultLocation, 10f | |
) | |
) | |
} | |
// Use rememberMarkerState for the marker position - this will update the marker on the map | |
val markerState = rememberMarkerState(position = defaultLocation) | |
// Animate camera to the new location | |
LaunchedEffect(newLocation) { | |
newLocation?.let { | |
items.clear() | |
coroutineScope.launch { | |
cameraPositionState.animate( | |
CameraUpdateFactory.newLatLngZoom(it.latLng, 12f) | |
) | |
// Update the marker state position | |
markerState.position = it.latLng | |
} | |
} | |
} | |
fun Modifier.onPointerInteractionStartEnd( | |
onPointerStart: () -> Unit, | |
onPointerEnd: () -> Unit, | |
) = pointerInput(onPointerStart, onPointerEnd) { | |
awaitEachGesture { | |
awaitFirstDown(requireUnconsumed = false) | |
onPointerStart() | |
do { | |
val event = awaitPointerEvent() | |
} while (event.changes.any { it.pressed }) | |
onPointerEnd() | |
} | |
} | |
// Display Google Map | |
GoogleMap( | |
modifier = modifier.onPointerInteractionStartEnd( | |
onPointerStart = { | |
isMapMoving.value = true | |
}, | |
onPointerEnd = { | |
isMapMoving.value = false | |
} | |
), | |
cameraPositionState = cameraPositionState, | |
uiSettings = MapUiSettings( | |
zoomControlsEnabled = true, | |
scrollGesturesEnabled = true, | |
scrollGesturesEnabledDuringRotateOrZoom = true | |
), | |
onMapClick = { latLng -> | |
// Update the marker state position when map is clicked | |
markerState.position = latLng | |
onMapLatLngClicked(latLng) | |
} | |
) { | |
// Use the markerState instead of creating a new Marker each time | |
Marker(state = markerState) | |
} | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\ui\screens\main\home\notifications\search\filter\Status.kt | |
```kt | |
package com.divadventure.ui.screens.main.home.notifications.search.filter | |
import androidx.compose.foundation.background | |
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.fillMaxSize | |
import androidx.compose.foundation.layout.padding | |
import androidx.compose.runtime.Composable | |
import androidx.compose.runtime.getValue | |
import androidx.compose.runtime.mutableStateOf | |
import androidx.compose.runtime.remember | |
import androidx.compose.runtime.setValue | |
import androidx.compose.ui.Modifier | |
import androidx.compose.ui.graphics.Color | |
import androidx.compose.ui.unit.dp | |
import com.divadventure.data.navigation.NavigationEvent | |
import com.divadventure.data.navigation.NavigationViewModel | |
import com.divadventure.ui.ApplyButton | |
import com.divadventure.ui.HeaderWithCloseButton | |
import com.divadventure.ui.screens.main.home.notifications.search.sortby.SortOptions | |
import com.divadventure.viewmodel.HomeIntent | |
import com.divadventure.viewmodel.HomeViewModel | |
import com.divadventure.viewmodel.MainViewModel | |
@Composable | |
fun Status( | |
paddingValues: PaddingValues, | |
navigationViewModel: NavigationViewModel, | |
mainViewModel: MainViewModel, | |
homeViewModel: HomeViewModel | |
) { | |
var state = homeViewModel.state | |
val sortOptions = listOf("Past", "Active", "Upcoming") // List of options | |
var selectedSort by remember { mutableStateOf(state.value.filters.state) } | |
Box( | |
modifier = Modifier | |
.fillMaxSize() | |
.background(Color.White) | |
) { | |
Column( | |
verticalArrangement = Arrangement.spacedBy(20.dp), | |
modifier = Modifier | |
.padding(paddingValues) | |
.fillMaxSize() | |
.background(Color(0xFFF2F2F7)) | |
) { | |
HeaderWithCloseButton(title = "Status") { | |
navigationViewModel.navigate(NavigationEvent.PopBackStack) | |
} | |
// Pass the list of options to SortOptions | |
SortOptions( | |
options = sortOptions, | |
selectedSort = selectedSort ?: "", | |
onSortSelected = { selectedSort = it } | |
) | |
ApplyButton(onClick = { | |
homeViewModel.sendIntent(HomeIntent.SetStatus(selectedSort)) | |
navigationViewModel.navigate(NavigationEvent.PopBackStack) | |
}) | |
} | |
} | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\ui\screens\main\home\notifications\search\Search.kt | |
```kt | |
package com.divadventure.ui.screens.main.home.notifications.search | |
import androidx.compose.foundation.Image | |
import androidx.compose.foundation.background | |
import androidx.compose.foundation.clickable | |
import androidx.compose.foundation.layout.Arrangement | |
import androidx.compose.foundation.layout.Column | |
import androidx.compose.foundation.layout.PaddingValues | |
import androidx.compose.foundation.layout.Row | |
import androidx.compose.foundation.layout.padding | |
import androidx.compose.foundation.layout.size | |
import androidx.compose.material3.Text | |
import androidx.compose.runtime.Composable | |
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.graphics.Color | |
import androidx.compose.ui.res.painterResource | |
import androidx.compose.ui.text.TextStyle | |
import androidx.compose.ui.unit.dp | |
import com.divadventure.R | |
import com.divadventure.ui.BackCompose | |
import com.divadventure.ui.SearchField | |
import com.divadventure.viewmodel.HomeIntent | |
import com.divadventure.viewmodel.HomeViewModel | |
import com.divadventure.viewmodel.MainIntent | |
import com.divadventure.viewmodel.MainViewModel | |
// یک کامپوز برای جستجو بین دویدادها و همچنین فیلتر کردن و مرتب کردن به ترتیب دلخواه است | |
@Composable | |
fun Search(mainViewModel: MainViewModel, homeViewModel: HomeViewModel, padding: PaddingValues) { | |
var queryText by remember { mutableStateOf("") } | |
Column( | |
modifier = Modifier | |
.background(color = Color.White) | |
.padding(top = padding.calculateTopPadding()) | |
) { | |
BackCompose("Search") { | |
homeViewModel.sendIntent(HomeIntent.SwitchShowSearchbar) | |
} | |
SearchField(queryText = queryText, onQueryChanged = { | |
queryText = it | |
homeViewModel.sendIntent(HomeIntent.LoadAdventuresData(queryText)) | |
}) | |
Row( | |
horizontalArrangement = Arrangement.spacedBy(20.dp), modifier = Modifier.padding(20.dp) | |
) { | |
Row( | |
modifier = Modifier.clickable { | |
mainViewModel.sendIntent(MainIntent.GotoFilter) | |
}, | |
horizontalArrangement = Arrangement.spacedBy(10.dp), | |
verticalAlignment = Alignment.CenterVertically, | |
) { | |
Image( | |
modifier = Modifier.size(25.dp), | |
colorFilter = androidx.compose.ui.graphics.ColorFilter.tint( | |
getFilterColor( | |
homeViewModel | |
) | |
), | |
painter = painterResource(id = R.drawable.ic_filter), | |
contentDescription = "Placeholder image" | |
) | |
Text("Filter", style = TextStyle(color = Color(0xFF848484))) | |
} | |
Row( | |
modifier = Modifier.clickable { | |
mainViewModel.sendIntent(MainIntent.GotoSortBy) | |
}, | |
horizontalArrangement = Arrangement.spacedBy(10.dp), | |
verticalAlignment = Alignment.CenterVertically, | |
) { | |
Image( | |
modifier = Modifier.size(25.dp), | |
colorFilter = androidx.compose.ui.graphics.ColorFilter.tint( | |
getSortByColor( | |
homeViewModel | |
) | |
), | |
painter = painterResource(id = R.drawable.ic_sort_by), | |
contentDescription = "Placeholder image" | |
) | |
Text("Sort by", style = TextStyle(color = Color(0xFF848484))) | |
} | |
} | |
} | |
} | |
fun getFilterColor(homeViewModel: HomeViewModel): Color { | |
val state = homeViewModel.state.value | |
if (state.filters.state.isNullOrEmpty() && | |
state.filters.startDate.isNullOrEmpty() && | |
state.filters.endDate.isNullOrEmpty() && | |
state.filters.state.isNullOrEmpty() && | |
state.filters.interests.isNullOrEmpty() && | |
state.filters.locationLAt == null && | |
state.filters.locationLng == null | |
) { | |
return Color(0xFF848484) | |
} else return Color(0xFF30D158) | |
} | |
fun getSortByColor(homeViewModel: HomeViewModel): Color { | |
val state = homeViewModel.state.value | |
if (state.filters.orderBy.isNullOrEmpty()) { | |
return Color(0xFF848484) | |
} else { | |
return Color(0xFF30D158) | |
} | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\ui\screens\main\home\notifications\search\sortby\SortBy.kt | |
```kt | |
package com.divadventure.ui.screens.main.home.notifications.search.sortby | |
import androidx.compose.foundation.background | |
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.fillMaxSize | |
import androidx.compose.foundation.layout.padding | |
import androidx.compose.foundation.lazy.LazyColumn | |
import androidx.compose.runtime.Composable | |
import androidx.compose.runtime.getValue | |
import androidx.compose.runtime.mutableStateOf | |
import androidx.compose.runtime.remember | |
import androidx.compose.runtime.setValue | |
import androidx.compose.ui.Modifier | |
import androidx.compose.ui.graphics.Color | |
import androidx.compose.ui.unit.dp | |
import com.divadventure.data.navigation.NavigationEvent | |
import com.divadventure.data.navigation.NavigationViewModel | |
import com.divadventure.ui.ApplyButton | |
import com.divadventure.ui.BackCompose | |
import com.divadventure.ui.SelectionRow | |
import com.divadventure.ui.SimpleSelectionImage | |
import com.divadventure.ui.SortDivider | |
import com.divadventure.viewmodel.HomeIntent | |
import com.divadventure.viewmodel.HomeViewModel | |
import com.divadventure.viewmodel.MainViewModel | |
@Composable | |
fun SortBy( | |
mainViewModel: MainViewModel, | |
navigationViewModel: NavigationViewModel, | |
homeViewModel: HomeViewModel, | |
padding: PaddingValues | |
) { | |
var state = homeViewModel.state | |
val sortOptions = listOf("Popular", "Recent", "Near me") // List of options | |
var selectedSort by remember { mutableStateOf(state.value.filters.orderBy ?: "") } | |
Box( | |
modifier = Modifier | |
.fillMaxSize() | |
.background(Color.White) | |
) { | |
Column( | |
verticalArrangement = Arrangement.spacedBy(20.dp), | |
modifier = Modifier | |
.padding(padding) | |
.fillMaxSize() | |
.background(Color(0xFFF2F2F7)) | |
) { | |
BackCompose( | |
"Sort by", modifier = Modifier | |
) { | |
navigationViewModel.navigate(NavigationEvent.PopBackStack) | |
} | |
// Pass the list of options to SortOptions | |
SortOptions( | |
options = sortOptions, | |
selectedSort = selectedSort, | |
onSortSelected = { selectedSort = it } | |
) | |
ApplyButton(onClick = { | |
homeViewModel.sendIntent(HomeIntent.ApplySortBy(selectedSort)) | |
navigationViewModel.navigate(NavigationEvent.PopBackStack) | |
}) | |
} | |
} | |
} | |
@Composable | |
fun SortOptions( | |
options: List<String>, // List of sort options passed as a parameter | |
selectedSort: String, | |
onSortSelected: (String) -> Unit | |
) { | |
LazyColumn( | |
modifier = Modifier.background(color = Color.White), | |
verticalArrangement = Arrangement.spacedBy(10.dp) | |
) { | |
items(options.size) { index -> | |
val sortOption = options[index] | |
// Sort Option | |
SelectionRow( | |
text = sortOption, | |
isSelected = selectedSort == sortOption, | |
selectionImage = { isSelected -> SimpleSelectionImage(isSelected) }, | |
onClick = { onSortSelected(sortOption) } | |
) | |
// Add a divider between items, but not after the last item | |
if (index < options.size - 1) { | |
SortDivider() | |
} | |
} | |
} | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\ui\screens\main\Main.kt | |
```kt | |
package com.divadventure.ui.screens.main.home | |
import android.app.Activity | |
import android.widget.Toast | |
import androidx.activity.compose.BackHandler | |
import androidx.compose.foundation.background | |
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.fillMaxHeight | |
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.shape.CircleShape | |
import androidx.compose.material3.BottomAppBar | |
import androidx.compose.material3.Icon | |
import androidx.compose.material3.NavigationBarItem | |
import androidx.compose.material3.NavigationBarItemDefaults | |
import androidx.compose.material3.Scaffold | |
import androidx.compose.material3.Text | |
import androidx.compose.runtime.Composable | |
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.Alignment | |
import androidx.compose.ui.Modifier | |
import androidx.compose.ui.draw.clip | |
import androidx.compose.ui.graphics.Color | |
import androidx.compose.ui.graphics.RectangleShape | |
import androidx.compose.ui.graphics.vector.ImageVector | |
import androidx.compose.ui.platform.LocalContext | |
import androidx.compose.ui.res.vectorResource | |
import androidx.compose.ui.text.TextStyle | |
import androidx.compose.ui.text.style.TextAlign | |
import androidx.compose.ui.unit.dp | |
import androidx.compose.ui.unit.sp | |
import androidx.hilt.navigation.compose.hiltViewModel | |
import androidx.lifecycle.compose.LocalLifecycleOwner | |
import androidx.lifecycle.compose.collectAsStateWithLifecycle | |
import androidx.navigation.NavDestination.Companion.hierarchy | |
import androidx.navigation.NavGraph.Companion.findStartDestination | |
import androidx.navigation.NavHostController | |
import androidx.navigation.compose.NavHost | |
import androidx.navigation.compose.composable | |
import androidx.navigation.compose.currentBackStackEntryAsState | |
import androidx.navigation.compose.rememberNavController | |
import com.divadventure.R | |
import com.divadventure.data.navigation.NavigationViewModel | |
import com.divadventure.data.navigation.rememberCustomNavigationStackManager | |
import com.divadventure.ui.screens.main.add.AddOrEditAdventure | |
import com.divadventure.ui.screens.main.profile.MyProfile | |
import com.divadventure.ui.theme.SystemUIManager | |
import com.divadventure.viewmodel.HomeViewModel | |
import com.divadventure.viewmodel.MainViewModel | |
import com.divadventure.viewmodel.ManageAdventureViewModel | |
import com.divadventure.viewmodel.ProfileViewModel | |
import kotlinx.coroutines.launch | |
import timber.log.Timber | |
/* | |
* این یک برنامه یشبکه اجتماعی است که افراد می توانند ماجراجویی ها و سفرهای خود را به اشتراک بگذراند و مثل اینستگرام می توانند تا افرادی یا ماجراجویی هایی را دنبال کنند، همچنین امکان ایجاد سفرها یا ماجراجویی های جدیدی نیز وجود دارد. | |
* */ | |
// View mode options for displaying content | |
@Composable | |
fun MainScreen( | |
homeViewModel: HomeViewModel, | |
mainViewModel: MainViewModel, | |
adventureViewModel: ManageAdventureViewModel, | |
navigationViewModel: NavigationViewModel, | |
padding: PaddingValues, | |
) { | |
val navController = rememberNavController() | |
val context = LocalContext.current | |
val scope = rememberCoroutineScope() | |
var backPressedTime by remember { mutableStateOf(0L) } // Use 'by' for delegation | |
SystemUIManager( | |
isDarkThemeForBottom = false, | |
isDarkThemeForStatusBar = false, | |
statusBarColor = Color.Transparent, | |
navigationBarColor = Color.Transparent, | |
false, | |
false, | |
onSystemBarsVisibilityChange = { isVisible1: Boolean, isVisible2: Boolean -> | |
}) | |
val navBackStackEntry by navController.currentBackStackEntryAsState() | |
val currentDestination = navBackStackEntry?.destination | |
val currentScreenTitle = when (currentDestination?.route) { | |
BottomNavItem.Home.route -> BottomNavItem.Home.label | |
BottomNavItem.Add.route -> BottomNavItem.Add.label | |
BottomNavItem.Profile.route -> BottomNavItem.Profile.label | |
else -> "Unknown" | |
} | |
val customNavigationStackManager = rememberCustomNavigationStackManager(navController) | |
navigationViewModel.setNavigationManager(customNavigationStackManager) | |
val lifecycleOwner = LocalLifecycleOwner.current | |
val navigationEvent by navigationViewModel.navigationEvent.collectAsStateWithLifecycle( | |
lifecycleOwner | |
) | |
val mainViewModel = hiltViewModel<MainViewModel>() | |
BackHandler(enabled = true) { // Enable the BackHandler | |
if (System.currentTimeMillis() - backPressedTime < 2000) { // 2 seconds threshold | |
// If second back press is within 2 seconds, finish the activity | |
(context as? Activity)?.finish() | |
} else { | |
// Otherwise, show a toast and update the back press time | |
scope.launch { | |
Toast.makeText(context, "Press back again to exit", Toast.LENGTH_SHORT).show() | |
} | |
backPressedTime = System.currentTimeMillis() | |
} | |
} | |
Scaffold(bottomBar = { | |
BottomNavigationBar( | |
navController = navController, | |
navigationViewModel, | |
mainViewModel = mainViewModel, | |
padding | |
) | |
}, content = { innerPadding -> | |
NavHost( | |
navController = navController, | |
startDestination = BottomNavItem.Home.route, | |
) { | |
composable(BottomNavItem.Home.route) { | |
Home( | |
mainViewModel, | |
homeViewModel, | |
navigationViewModel, | |
navController, | |
padding | |
) // Your Home page content | |
} | |
composable(BottomNavItem.Profile.route) { | |
MyProfile( | |
navigationViewModel, | |
padding, | |
hiltViewModel<MainViewModel>(), | |
hiltViewModel<ProfileViewModel>(), | |
) // Your Profile page content | |
} | |
composable(BottomNavItem.Add.route) { | |
AddOrEditAdventure( | |
padding, | |
mainViewModel, | |
adventureViewModel, | |
navigationViewModel | |
) // Your Notifications page content | |
} | |
} | |
}) | |
} | |
sealed class BottomNavItem(val route: String, val label: String, val icon: Int) { | |
object Add : BottomNavItem("add", "", R.drawable.ic_add) | |
object Profile : BottomNavItem("profile", "Profile", R.drawable.ic_person_svg) | |
object Home : BottomNavItem("home", "Home", R.drawable.ic_home) | |
} | |
@Composable | |
fun BottomNavigationBar( | |
navController: NavHostController, | |
navigationViewModel: NavigationViewModel, | |
mainViewModel: MainViewModel, | |
padding: PaddingValues | |
) { | |
val navItems = listOf( | |
BottomNavItem.Home, BottomNavItem.Add, BottomNavItem.Profile | |
) | |
Column(modifier = Modifier.fillMaxWidth()) { | |
Box( | |
modifier = Modifier | |
.fillMaxWidth() | |
.background(Color(0xFF30D158)) | |
.height(2.dp) | |
) { | |
Box( | |
modifier = Modifier | |
.fillMaxWidth(1f / 3f) | |
.height(2.dp) | |
.align(Alignment.TopCenter) | |
// .offset(x = -(1 / 3f).dp) | |
.background(Color.White) | |
) | |
}/* این کامپوز شامل نویگیشین اصلی برنامه است که امکان جایبجایی بین قسمت های اصلی برنامه را می دهد */ | |
BottomAppBar( | |
modifier = Modifier.height(56.dp + padding.calculateBottomPadding()), // Decreased height | |
containerColor = Color(0xFF30D158), contentColor = Color.White, | |
) { | |
val navBackStackEntry by navController.currentBackStackEntryAsState() | |
val currentDestination = navBackStackEntry?.destination | |
navItems.forEachIndexed { index, item -> | |
val selected = currentDestination?.hierarchy?.any { it.route == item.route } == true | |
NavigationBarItem( | |
colors = NavigationBarItemDefaults.colors( | |
indicatorColor = Color.Transparent | |
), | |
icon = { | |
NavigationItem( | |
item = item, index = index | |
) | |
}, | |
alwaysShowLabel = true, | |
selected = selected, | |
onClick = { | |
Timber.d("Item selected: ${item.label}") | |
//mainViewModel.sendIntent(MainIntent.clearAllShared) | |
navController.navigate(item.route) { | |
popUpTo(navController.graph.findStartDestination().id) { | |
saveState = true | |
} | |
launchSingleTop = true | |
restoreState = true | |
} | |
}, | |
) | |
} | |
} | |
} | |
} | |
@Composable | |
fun NavigationItem( | |
item: BottomNavItem, index: Int, modifier: Modifier = Modifier | |
) { | |
Box( | |
modifier = modifier.fillMaxHeight(), contentAlignment = Alignment.Center | |
) { | |
Column( | |
modifier = Modifier.align(Alignment.Center), | |
verticalArrangement = Arrangement.Center, | |
horizontalAlignment = Alignment.CenterHorizontally | |
) { | |
val iconSize = if (index == 1) 40.dp else 30.dp | |
val iconShape = if (index == 1) CircleShape else RectangleShape | |
val iconBackground = | |
if (index == 1) Color.White.copy(alpha = 0.28f) else Color.Transparent | |
val iconPadding = if (index == 1) 10.dp else 5.dp | |
Icon( | |
imageVector = ImageVector.vectorResource(item.icon), | |
contentDescription = item.label, | |
tint = Color.White, | |
modifier = Modifier | |
.align(Alignment.CenterHorizontally) | |
.size(iconSize) | |
.clip(iconShape) | |
.background(iconBackground) | |
.padding(iconPadding) | |
) | |
if (index != 1) { | |
Text( | |
text = item.label, | |
textAlign = TextAlign.Center, | |
style = TextStyle(fontSize = 14.sp, color = Color.White) | |
) | |
} | |
} | |
} | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\ui\screens\main\profile\AccountSettings.kt | |
```kt | |
package com.divadventure.ui.screens.main.profile | |
import androidx.compose.foundation.background | |
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.aspectRatio | |
import androidx.compose.foundation.layout.fillMaxSize | |
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.rememberScrollState | |
import androidx.compose.foundation.shape.CircleShape | |
import androidx.compose.foundation.shape.RoundedCornerShape | |
import androidx.compose.foundation.verticalScroll | |
import androidx.compose.material3.Card | |
import androidx.compose.material3.HorizontalDivider | |
import androidx.compose.material3.Icon | |
import androidx.compose.material3.Text | |
import androidx.compose.material3.TextButton | |
import androidx.compose.runtime.Composable | |
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.graphics.Color | |
import androidx.compose.ui.platform.LocalDensity | |
import androidx.compose.ui.res.painterResource | |
import androidx.compose.ui.text.TextStyle | |
import androidx.compose.ui.text.font.FontWeight | |
import androidx.compose.ui.unit.dp | |
import com.divadventure.R | |
import com.divadventure.ui.BackCompose | |
import com.divadventure.ui.ItemTextClickIcon | |
import com.divadventure.ui.PersonalInfoTextField | |
import com.divadventure.ui.SimpleTextField | |
import com.divadventure.ui.TitleCompose | |
import com.google.maps.android.compose.GoogleMap | |
@Composable | |
fun AccountSettings( | |
paddingValues: PaddingValues | |
) { | |
Box( | |
modifier = Modifier | |
.fillMaxSize() | |
.background(Color.White) | |
.padding(paddingValues) | |
.background(Color(0xFFEFEFF4)) | |
) { | |
var userName by remember { mutableStateOf("USERNAME") } | |
var email by remember { mutableStateOf("EMAIL") } | |
var firstName by remember { mutableStateOf("Hasan") } | |
var lastName by remember { mutableStateOf("ALi") } | |
var birthDate by remember { mutableStateOf("Your Birth Date") } | |
var bio by remember { mutableStateOf("bio bio bio") } | |
var address by remember { mutableStateOf("Your Address") } | |
val scrollState = rememberScrollState() | |
Column( | |
modifier = Modifier.verticalScroll(scrollState) // Enable scrolling | |
) { | |
BackCompose(text = "Account Settings") {} | |
Card( | |
modifier = Modifier | |
.align(Alignment.CenterHorizontally) | |
.size(100.dp), | |
shape = CircleShape | |
) {} | |
TitleCompose("Contact Info") | |
Column( | |
modifier = Modifier | |
.padding(horizontal = 20.dp) | |
.fillMaxWidth() | |
.background(Color.White, RoundedCornerShape(8.dp)) | |
) { | |
SimpleTextField( | |
value = userName, | |
onValueChange = { | |
userName = it | |
}, | |
) | |
HorizontalDivider( | |
color = Color(0x3C3C435C).copy(alpha = 0.36f), | |
modifier = Modifier | |
.height(1.dp) | |
.fillMaxWidth() | |
.background(Color(0x3C3C435C)) | |
) | |
SimpleTextField( | |
value = email, | |
onValueChange = { | |
email = it | |
}, | |
) | |
} | |
TitleCompose("Personal Info") | |
Column( | |
modifier = Modifier | |
.padding(horizontal = 20.dp) | |
.fillMaxWidth() | |
.background(Color.White, RoundedCornerShape(10.dp)) | |
) { | |
PersonalInfoTextField( | |
title = "First Name", value = firstName, { | |
firstName = it | |
}, Modifier.fillMaxWidth() | |
) | |
HorizontalDivider( | |
color = Color(0x3C3C435C).copy(alpha = 0.36f), | |
modifier = Modifier | |
.height(1.dp) | |
.padding(start = 20.dp) | |
.fillMaxWidth() | |
.background(Color(0x3C3C435C)) | |
) | |
PersonalInfoTextField( | |
title = "Last Name", value = lastName, { | |
lastName = it | |
}, Modifier.fillMaxWidth() | |
) | |
HorizontalDivider( | |
color = Color(0x3C3C435C).copy(alpha = 0.36f), | |
modifier = Modifier | |
.height(1.dp) | |
.padding(start = 20.dp) | |
.fillMaxWidth() | |
.background(Color(0x3C3C435C).copy(alpha = 0.36f)) | |
) | |
PersonalInfoTextField( | |
title = "Birth Date", | |
value = birthDate, | |
onValueChange = { birthDate = it }, | |
modifier = Modifier.fillMaxWidth(), | |
) | |
} | |
Column( | |
modifier = Modifier | |
.padding(start = 20.dp, end = 20.dp, top = 20.dp) | |
.fillMaxWidth() | |
.background(Color.White, RoundedCornerShape(10.dp)) | |
) { | |
PersonalInfoTextField( | |
title = "Biography", value = "Bio", onValueChange = { | |
bio = it | |
}, modifier = Modifier.fillMaxWidth(), minLines = 5 | |
) | |
} | |
Column( | |
modifier = Modifier | |
.padding(start = 20.dp, end = 20.dp, top = 20.dp) | |
.fillMaxWidth() | |
.background(Color.White, RoundedCornerShape(10.dp)) | |
) { | |
ObligatoryInterestsComposable("Interests") | |
} | |
TitleCompose("Location") | |
Column( | |
modifier = Modifier | |
.padding(start = 20.dp, end = 20.dp) | |
.fillMaxWidth() | |
.background(Color.White, RoundedCornerShape(10.dp)) | |
) { | |
SimpleTextField( | |
value = address, | |
onValueChange = { address = it }, | |
modifier = Modifier, | |
descLines = 1 | |
) | |
} | |
GoogleMap( | |
Modifier | |
.padding(top = 20.dp) | |
.fillMaxSize() | |
.aspectRatio(1f), | |
) | |
Column( | |
modifier = Modifier | |
.padding(start = 20.dp, end = 20.dp, top = 20.dp, bottom = 30.dp) | |
.fillMaxWidth() | |
.background(Color.White, RoundedCornerShape(8.dp)) | |
) { | |
ChangePassword() | |
HorizontalDivider( | |
color = Color(0x3C3C435C).copy(alpha = 0.36f), | |
modifier = Modifier | |
.height(1.dp) | |
.fillMaxWidth() | |
.background(Color(0x3C3C435C)) | |
) | |
DeleteAccount() | |
} | |
} | |
} | |
} | |
@Composable | |
fun DeleteAccount() { | |
TextButton(onClick = {}) { | |
Text( | |
fontSize = with(LocalDensity.current) { 13.dp.toSp() }, | |
text = "Delete Account", | |
style = TextStyle(color = Color.Black, fontWeight = FontWeight.W400) | |
) | |
} | |
} | |
@Composable | |
fun ChangePassword() { | |
TextButton(onClick = {}) { | |
Text( | |
text = "Delete Account", | |
fontSize = with(LocalDensity.current) { 13.dp.toSp() }, | |
style = TextStyle(color = Color(0xFFFF0000), fontWeight = FontWeight.W400) | |
) | |
} | |
} | |
@Composable | |
fun ObligatoryInterestsComposable(title: String) { | |
ItemTextClickIcon( | |
title = title, content = { | |
Row(modifier = Modifier.padding()) { | |
Text("Label", style = TextStyle(color = Color(0x3C3C4399).copy(alpha = 0.6f))) | |
Icon( | |
modifier = Modifier.padding(start = 10.dp), | |
painter = painterResource(id = R.drawable.right_chevron), | |
tint = Color(0x3C3C4399).copy(alpha = 0.6f), | |
contentDescription = "" | |
) | |
} | |
}) | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\ui\screens\main\profile\NotificationsSettings.kt | |
```kt | |
package com.divadventure.ui.screens.main.profile | |
import androidx.compose.foundation.background | |
import androidx.compose.foundation.layout.Box | |
import androidx.compose.foundation.layout.Column | |
import androidx.compose.foundation.layout.PaddingValues | |
import androidx.compose.foundation.layout.fillMaxSize | |
import androidx.compose.foundation.layout.padding | |
import androidx.compose.foundation.lazy.rememberLazyListState | |
import androidx.compose.runtime.Composable | |
import androidx.compose.runtime.getValue | |
import androidx.compose.runtime.mutableStateOf | |
import androidx.compose.runtime.remember | |
import androidx.compose.runtime.setValue | |
import androidx.compose.ui.Modifier | |
import androidx.compose.ui.graphics.Color | |
import com.divadventure.ui.BackCompose | |
import com.divadventure.ui.screens.main.home.notifications.search.sortby.SortOptions | |
@Composable | |
fun NotificationsSettings(padding: PaddingValues) { | |
Box( | |
modifier = Modifier | |
.fillMaxSize() | |
.background(Color(0xFFEFEFF4)) | |
.padding(padding) | |
) { | |
val sortOptions = listOf("Email", "Application") // List of options | |
var selectedSort by remember { mutableStateOf(sortOptions[0]) } | |
val parentScrollState = rememberLazyListState() | |
val childScrollState = rememberLazyListState() | |
Column { | |
BackCompose( | |
"Notification", modifier = Modifier | |
) { | |
} | |
// Pass the list of options to SortOptions | |
SortOptions( | |
options = sortOptions, | |
selectedSort = selectedSort, | |
onSortSelected = { selectedSort = it } | |
) | |
} | |
} | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\ui\screens\main\profile\PrivacySettings.kt | |
```kt | |
package com.divadventure.ui.screens.main.profile | |
import androidx.compose.foundation.background | |
import androidx.compose.foundation.layout.Box | |
import androidx.compose.foundation.layout.Column | |
import androidx.compose.foundation.layout.PaddingValues | |
import androidx.compose.foundation.layout.fillMaxSize | |
import androidx.compose.foundation.layout.padding | |
import androidx.compose.foundation.lazy.LazyColumn | |
import androidx.compose.foundation.lazy.itemsIndexed | |
import androidx.compose.foundation.shape.RoundedCornerShape | |
import androidx.compose.material3.Text | |
import androidx.compose.runtime.Composable | |
import androidx.compose.runtime.mutableStateListOf | |
import androidx.compose.runtime.remember | |
import androidx.compose.ui.Modifier | |
import androidx.compose.ui.graphics.Color | |
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection | |
import androidx.compose.ui.input.nestedscroll.nestedScroll | |
import androidx.compose.ui.platform.LocalDensity | |
import androidx.compose.ui.text.font.FontWeight | |
import androidx.compose.ui.unit.dp | |
import com.divadventure.ui.BackCompose | |
import com.divadventure.ui.SelectionRow | |
import com.divadventure.ui.SimpleSelectionImage | |
import com.divadventure.ui.SortDivider | |
@Composable | |
fun PrivacySettings(padding: PaddingValues) { | |
val options = listOf("Only Me", "Friends Only", "Public") | |
val titles = listOf("Bio", "Location", "Birthday", "Adventures", "Friends List") | |
var selections = remember { mutableStateListOf<String>("", "", "", "", "") } | |
Box( | |
modifier = Modifier | |
.background(Color.White) | |
.padding(padding) | |
.background(Color(0xFFEFEFF4)) | |
.fillMaxSize() | |
) { | |
Column(modifier = Modifier.fillMaxSize()) { | |
// Back button at the top | |
BackCompose( | |
"Privacy Settings", modifier = Modifier | |
) {} | |
val nestedScrollConnection = remember { | |
object : NestedScrollConnection { | |
// Implement custom scroll handling if needed | |
} | |
} | |
// LazyColumn for displaying titles and options with constrained height | |
LazyColumn( | |
modifier = Modifier | |
.fillMaxSize() | |
.nestedScroll(nestedScrollConnection), | |
// Occupy remaining space in the Column | |
) { | |
itemsIndexed(titles) { index, title -> | |
TripleOptions( | |
title, | |
option1 = options[0], | |
option2 = options[1], | |
option3 = options[2], | |
selectedOption = selections[index], | |
onOptionSelected = { selected -> | |
selections[index] = (selected) | |
} | |
) | |
} | |
} | |
} | |
} | |
} | |
@Composable | |
fun TripleOptions( | |
title: String, | |
option1: String, | |
option2: String, | |
option3: String, | |
selectedOption: String, | |
onOptionSelected: (String) -> Unit | |
) { | |
Column { | |
Text( | |
modifier = Modifier.padding(start = 20.dp), | |
text = title, style = androidx.compose.ui.text.TextStyle( | |
color = Color.Black, | |
fontSize = with(LocalDensity.current) { 14.dp.toSp() }, | |
fontWeight = FontWeight.W300 | |
) | |
) | |
Column( | |
modifier = Modifier | |
.padding(20.dp) | |
.background(color = Color.White, shape = RoundedCornerShape(10.dp)) | |
// Optional global padding for the column | |
) { | |
// First Option | |
SelectionRow( | |
text = option1, | |
isSelected = selectedOption == option1, | |
selectionImage = { isSelected -> SimpleSelectionImage(isSelected) }, | |
onClick = { onOptionSelected(option1) } | |
) | |
SortDivider() // Divider after the first item | |
// Second Option | |
SelectionRow( | |
text = option2, | |
isSelected = selectedOption == option2, | |
selectionImage = { isSelected -> SimpleSelectionImage(isSelected) }, | |
onClick = { onOptionSelected(option2) } | |
) | |
SortDivider() // Divider after the second item | |
// Third Option | |
SelectionRow( | |
text = option3, | |
isSelected = selectedOption == option3, | |
selectionImage = { isSelected -> SimpleSelectionImage(isSelected) }, | |
onClick = { onOptionSelected(option3) } | |
) | |
// No divider after the third item (last one) | |
} | |
} | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\ui\screens\main\profile\Profile.kt | |
```kt | |
package com.divadventure.ui.screens.main.profile | |
import android.widget.Toast | |
import androidx.activity.OnBackPressedCallback | |
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner | |
import androidx.compose.foundation.Image | |
import androidx.compose.foundation.background | |
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.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.lazy.LazyColumn | |
import androidx.compose.foundation.rememberScrollState | |
import androidx.compose.foundation.shape.CircleShape | |
import androidx.compose.material3.ExperimentalMaterial3Api | |
import androidx.compose.material3.Text | |
import androidx.compose.runtime.Composable | |
import androidx.compose.runtime.DisposableEffect | |
import androidx.compose.runtime.LaunchedEffect | |
import androidx.compose.runtime.collectAsState | |
import androidx.compose.runtime.derivedStateOf | |
import androidx.compose.runtime.getValue | |
import androidx.compose.runtime.mutableStateListOf | |
import androidx.compose.runtime.mutableStateOf | |
import androidx.compose.runtime.remember | |
import androidx.compose.runtime.rememberCoroutineScope | |
import androidx.compose.runtime.setValue | |
import androidx.compose.runtime.toMutableStateList | |
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.input.nestedscroll.NestedScrollConnection | |
import androidx.compose.ui.input.nestedscroll.NestedScrollDispatcher | |
import androidx.compose.ui.layout.ContentScale | |
import androidx.compose.ui.platform.LocalContext | |
import androidx.compose.ui.platform.LocalDensity | |
import androidx.compose.ui.res.painterResource | |
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 coil.compose.rememberAsyncImagePainter | |
import com.divadventure.R | |
import com.divadventure.data.navigation.NavigationEvent | |
import com.divadventure.data.navigation.NavigationViewModel | |
import com.divadventure.domain.models.Adventurer | |
import com.divadventure.domain.models.Friend | |
import com.divadventure.ui.BackComposeMoreButton | |
import com.divadventure.ui.ControlButton | |
import com.divadventure.ui.Friends | |
import com.divadventure.ui.TabbedProfileSwitcher | |
import com.divadventure.ui.screens.main.home.AdventuresList | |
import com.divadventure.ui.screens.main.home.BinarySwitcher | |
import com.divadventure.ui.screens.main.home.SelectableCalendar | |
import com.divadventure.ui.screens.main.home.notifications.BottomSheetContent | |
import com.divadventure.ui.screens.main.home.notifications.GeneralBottomSheet | |
import com.divadventure.ui.screens.main.home.showTypes | |
import com.divadventure.viewmodel.MainIntent | |
import com.divadventure.viewmodel.MainUiEvent | |
import com.divadventure.viewmodel.MainViewModel | |
import com.divadventure.viewmodel.ProfileIntent | |
import com.divadventure.viewmodel.ProfileUIEvent | |
import com.divadventure.viewmodel.ProfileViewModel | |
import com.kizitonwose.calendar.compose.rememberCalendarState | |
import java.time.YearMonth | |
@OptIn(ExperimentalMaterial3Api::class) | |
@Composable | |
fun MyProfile( | |
navigationViewModel: NavigationViewModel, | |
paddings: PaddingValues = PaddingValues(), | |
mainViewModel: MainViewModel, | |
profileViewModel: ProfileViewModel, | |
) { | |
LaunchedEffect(true) { | |
profileViewModel.sendIntent(ProfileIntent.LoadMyFriends) | |
profileViewModel.sendIntent(ProfileIntent.LoadMyUserData) | |
} | |
var showBottomSheet by remember { mutableStateOf(false) } | |
LaunchedEffect(key1 = true) { | |
profileViewModel.uiEvent.collect { event -> | |
when (event) { | |
ProfileUIEvent.AnimateItem -> {} | |
is ProfileUIEvent.NavigateToNextScreen -> {} | |
is ProfileUIEvent.ShowBottomSheet -> { | |
showBottomSheet = true | |
} | |
ProfileUIEvent.ShowDialog -> {} | |
is ProfileUIEvent.ShowDim -> {} | |
is ProfileUIEvent.ShowSnackbar -> {} | |
} | |
} | |
} | |
LaunchedEffect(key1 = true) { | |
mainViewModel.uiEvent.collect { event -> | |
when (event) { | |
MainUiEvent.AnimateItem -> {} | |
is MainUiEvent.NavigateToNextScreen -> { | |
navigationViewModel.navigate(event.navigationEvent) | |
} | |
MainUiEvent.ShowDialog -> {} | |
is MainUiEvent.ShowDim -> {} | |
is MainUiEvent.ShowSnackbar -> {} | |
is MainUiEvent.AdventureAction -> { | |
} | |
} | |
} | |
} | |
// Get the Back Press Dispatcher | |
val backPressedDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher | |
// Register the Back Press Callback | |
DisposableEffect(backPressedDispatcher) { | |
val callback = object : OnBackPressedCallback(true) { | |
override fun handleOnBackPressed() { | |
// Trigger PopSpecific to navigate back without additional NavigateTo call | |
profileViewModel.navigate( | |
NavigationEvent.PopBackStack | |
) | |
} | |
} | |
backPressedDispatcher?.addCallback(callback) | |
// Cleanup callback | |
onDispose { | |
callback.remove() | |
} | |
} | |
if (showBottomSheet) { | |
val options = listOf( | |
"Account Settings", "Notification Settings", "Privacy Settings", "Logout" | |
) | |
GeneralBottomSheet( | |
Modifier.offset(y = -paddings.calculateBottomPadding()), | |
showBottomSheet = showBottomSheet, | |
onDismissRequest = { showBottomSheet = false }, | |
content = { | |
BottomSheetContent(options = options, onOptionClick = { option -> | |
// Handle option click (yes, maybe, no) | |
when (option) { | |
options[0] -> { | |
mainViewModel.sendIntent(MainIntent.GoAccountSettings) | |
} | |
options[1] -> { | |
mainViewModel.sendIntent(MainIntent.GoNotificationsSettings) | |
} | |
options[2] -> { | |
mainViewModel.sendIntent(MainIntent.GoPrivacySettings) | |
} | |
options[3] -> { | |
} | |
} | |
println("Option selected: $option") | |
showBottomSheet = false | |
}, onCancelClick = { | |
// Handle cancel click | |
println("Cancel clicked") | |
showBottomSheet = false | |
}, optionStyles = List(4) { | |
TextStyle( | |
color = Color.Black, | |
fontFamily = FontFamily(Font(R.font.sf_pro)), | |
fontSize = with(LocalDensity.current) { 14.dp.toSp() }) | |
}) | |
}) | |
} | |
ProfileContent( | |
paddings = paddings, | |
profileViewModel = profileViewModel, | |
mainViewModel = mainViewModel, | |
"Account Settings", | |
adventuresContent = { showTypes, selectedShowType, onSelectOption -> | |
MyAdventureContent( | |
showTypes = showTypes, | |
profileViewModel, | |
mainViewModel, | |
selectedShowType = selectedShowType, | |
onSelectOption = onSelectOption | |
) | |
}, | |
friendsContent = { | |
FriendsContent(profileViewModel) | |
}) | |
} | |
@Composable | |
fun ElseProfile( | |
paddings: PaddingValues = PaddingValues(), | |
mainViewModel: MainViewModel, | |
profileId: String, | |
profileViewModel: ProfileViewModel, | |
) { | |
val profileState = profileViewModel.state.collectAsState().value | |
LaunchedEffect(true) { | |
profileViewModel.sendIntent(ProfileIntent.CheckId(profileId)) | |
profileViewModel.sendIntent(ProfileIntent.LoadElseFriends(profileId)) | |
profileViewModel.sendIntent(ProfileIntent.LoadElseProfileData(profileId)) | |
} | |
ProfileContent( | |
paddings = paddings, | |
profileViewModel = profileViewModel, | |
mainViewModel = mainViewModel, | |
buttonTitle = if (profileState.statusWithUser.equals("friends", true) | |
) "Remove Friend" else "Add Friend", | |
adventuresContent = { showTypes, selectedShowType, onSelectOption -> | |
ElseAdventureContent( | |
showTypes = showTypes, | |
profileViewModel, | |
mainViewModel, | |
selectedShowType = selectedShowType, | |
onSelectOption = onSelectOption | |
) | |
}, | |
friendsContent = { | |
FriendsContent(profileViewModel) | |
}) | |
} | |
@OptIn(ExperimentalMaterial3Api::class) | |
@Composable | |
fun ProfileContent( | |
paddings: PaddingValues, | |
profileViewModel: ProfileViewModel, | |
mainViewModel: MainViewModel, | |
buttonTitle: String, | |
adventuresContent: @Composable (List<String>, String, (String) -> Unit) -> Unit, | |
friendsContent: @Composable () -> Unit, | |
) { | |
val showTypes = showTypes | |
var selectedShowType by remember { mutableStateOf(showTypes.first()) } | |
// Dispatcher and connection to enable nested scrolling | |
val parentNestedScrollDispatcher = remember { NestedScrollDispatcher() } | |
val parentNestedScrollConnection = remember { object : NestedScrollConnection {} } | |
// Main Profile Content Layout | |
Box( | |
modifier = Modifier | |
.background(Color.White) | |
.fillMaxSize() | |
.padding(paddings) | |
) { | |
var showBottomSheet by remember { mutableStateOf(false) } | |
// Create scroll states | |
val columnScrollState = rememberScrollState() | |
val coroutineScope = rememberCoroutineScope() | |
val verticalScroll = rememberScrollState() | |
// Observe scroll position of the ScrollState for the Column | |
/* LaunchedEffect(columnScrollState.value) { | |
// Sync the LazyColumn scroll positions | |
coroutineScope.launch { | |
lazyListState1.scrollToItem( | |
columnScrollState.value / 100 // Adjust to match offset scales | |
) | |
lazyListState2.scrollToItem( | |
columnScrollState.value / 100 | |
) | |
} | |
}*/ | |
LazyColumn( | |
modifier = Modifier.fillMaxSize() | |
) { | |
item { | |
BackComposeMoreButton(onBack = {}, onMore = { | |
profileViewModel.sendIntent(ProfileIntent.OpenProfileBottomSheet) | |
}) | |
} | |
item { | |
ImageProfile(profileViewModel) | |
} | |
item { | |
ControlButton( | |
text = buttonTitle, onClick = { | |
}) | |
} | |
item { | |
Characteristics(profileViewModel) | |
} | |
// Tabbed Profile Switcher | |
item { | |
TabbedProfileSwitcher(adventuresContent = { | |
adventuresContent( | |
showTypes, selectedShowType | |
) { selectedOption -> | |
selectedShowType = selectedOption | |
} | |
}, friendsContent = { | |
friendsContent() | |
}) | |
} | |
} | |
} | |
} | |
@Composable | |
fun ElseAdventureContent( | |
showTypes: List<String>, | |
profileViewModel: ProfileViewModel, | |
mainViewModel: MainViewModel, | |
selectedShowType: String, | |
onSelectOption: (String) -> Unit, | |
) { | |
val context = LocalContext.current | |
BinarySwitcher( | |
modifier = Modifier, | |
options = showTypes, | |
selectedOption = selectedShowType, | |
onSelectOption = { selectedOption -> | |
onSelectOption(selectedOption) | |
Toast.makeText(context, "Option selected: $selectedOption", Toast.LENGTH_SHORT).show() | |
}, | |
calendarContent = { | |
SelectableCalendar( | |
PaddingValues(), | |
false, | |
adventuresList = profileViewModel.state.collectAsState().value.adventuresList, | |
isAdventuresLoading = false | |
) | |
}, | |
listContent = { | |
var onClickItem: ((Adventurer) -> Unit) = { adventurer -> | |
mainViewModel.sendIntent(MainIntent.GotoProfile(adventurer.id)) | |
} | |
AdventuresList(viewModel = profileViewModel, mainViewModel, onClickItem, false) | |
LaunchedEffect(true) { | |
profileViewModel.sendIntent(ProfileIntent.LoadElseAdventures) | |
} | |
}) | |
} | |
@Composable | |
fun MyAdventureContent( | |
showTypes: List<String>, | |
profileViewModel: ProfileViewModel, | |
mainViewModel: MainViewModel, | |
selectedShowType: String, | |
onSelectOption: (String) -> Unit, | |
) { | |
val context = LocalContext.current | |
val calendarState = rememberCalendarState( | |
firstVisibleMonth = YearMonth.now(), | |
startMonth = YearMonth.now().minusYears(50), | |
endMonth = YearMonth.now().plusMonths(50) | |
) | |
var currentMonth by remember { | |
mutableStateOf( | |
calendarState.layoutInfo.visibleMonthsInfo.maxByOrNull { it.size }?.month?.yearMonth | |
) | |
} | |
val totalAdventures by remember(profileViewModel.state.value.adventuresList, currentMonth) { | |
derivedStateOf { | |
profileViewModel.state.value.adventuresList.filter { | |
it.startsAt.split("-")[0] == currentMonth?.year.toString() && | |
it.startsAt.split("-")[1] == currentMonth?.month?.value.toString() | |
}.toMutableStateList() | |
} | |
} | |
BinarySwitcher( | |
modifier = Modifier, | |
options = showTypes, | |
selectedOption = selectedShowType, | |
onSelectOption = { selectedOption -> | |
onSelectOption(selectedOption) | |
Toast.makeText(context, "Option selected: $selectedOption", Toast.LENGTH_SHORT).show() | |
}, | |
calendarContent = { | |
SelectableCalendar( | |
PaddingValues(), | |
false, | |
adventuresList = totalAdventures, | |
onChangeMonth = { yearMonth -> | |
currentMonth = yearMonth | |
}, | |
isAdventuresLoading = false | |
) | |
}, | |
listContent = { | |
var onClickItem: ((Adventurer) -> Unit) = { adventurer -> | |
mainViewModel.sendIntent(MainIntent.GotoProfile(adventurer.id)) | |
} | |
AdventuresList(viewModel = profileViewModel, mainViewModel, onClickItem, false) | |
LaunchedEffect(true) { | |
profileViewModel.sendIntent(ProfileIntent.LoadMyAdventures) | |
} | |
}) | |
} | |
@Composable | |
fun FriendsContent(viewModel: ProfileViewModel) { | |
var friendsList = remember { mutableStateListOf<Friend>() } | |
/* | |
if (viewModel is ProfileViewModel) { | |
friendsList = viewModel.state.collectAsState().value.friends.toMutableStateList() | |
} else if (viewModel is MainViewModel) { | |
friendsList = viewModel.state.collectAsState().value.friends.toMutableStateList() | |
} | |
*/ | |
friendsList = viewModel.state.collectAsState().value.friends.toMutableStateList() | |
Friends( | |
friends = friendsList | |
) | |
} | |
@Composable | |
fun ImageProfile(profileViewModel: ProfileViewModel) { | |
val state = profileViewModel.state.collectAsState().value | |
Column( | |
modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally | |
) { | |
Image( | |
contentScale = ContentScale.Crop, | |
painter = rememberAsyncImagePainter(R.drawable.random_image_5), | |
contentDescription = "Profile Image", | |
modifier = Modifier | |
.size(150.dp) | |
.clip(CircleShape) | |
.background(Color.Gray, CircleShape) | |
.align(Alignment.CenterHorizontally) | |
) | |
Text( | |
modifier = Modifier.padding(5.dp), | |
text = "${state.firstName} ${state.lastName}", | |
style = TextStyle(fontWeight = FontWeight.W600), | |
fontFamily = FontFamily(Font(R.font.sf_pro)) | |
) | |
Text( | |
text = "@${state.username}", | |
style = TextStyle(color = Color(0xFF848484)), | |
modifier = Modifier.padding(horizontal = 5.dp) | |
) | |
} | |
} | |
@Composable | |
fun Characteristics(profileViewModel: ProfileViewModel) { | |
val state = profileViewModel.state.collectAsState().value | |
Row( | |
Modifier | |
.fillMaxWidth() | |
.padding(vertical = 10.dp), | |
horizontalArrangement = Arrangement.spacedBy(20.dp, Alignment.CenterHorizontally), | |
verticalAlignment = Alignment.CenterVertically | |
) { | |
CharacteristicsItem( | |
modifier = Modifier.align(Alignment.CenterVertically), | |
imageId = R.drawable.ic_locate, | |
text = state.location, | |
) | |
CharacteristicsItem( | |
modifier = Modifier.align(Alignment.CenterVertically), | |
imageId = R.drawable.ic_calendar2, text = state.birthdate | |
) | |
} | |
} | |
@Composable | |
fun CharacteristicsItem(modifier: Modifier = Modifier, imageId: Int, text: String) { | |
Row( | |
modifier = modifier, horizontalArrangement = Arrangement.spacedBy(5.dp) | |
) { | |
Image( | |
painter = painterResource(id = imageId), | |
contentDescription = "Characteristic Image", | |
modifier = Modifier.size(20.dp) | |
) | |
Box( | |
modifier = Modifier.height(20.dp), contentAlignment = Alignment.CenterStart | |
) { | |
Text( | |
text = text, | |
modifier = Modifier.align(Alignment.Center), | |
style = TextStyle(color = Color.Black) | |
) | |
} | |
} | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\ui\screens\Onboarding.kt | |
```kt | |
package com.divadventure.ui.screens | |
import android.os.CountDownTimer | |
import androidx.compose.foundation.clickable | |
import androidx.compose.foundation.layout.Box | |
import androidx.compose.foundation.layout.Column | |
import androidx.compose.foundation.layout.PaddingValues | |
import androidx.compose.foundation.layout.fillMaxSize | |
import androidx.compose.foundation.layout.fillMaxWidth | |
import androidx.compose.foundation.layout.height | |
import androidx.compose.foundation.layout.padding | |
import androidx.compose.foundation.shape.RoundedCornerShape | |
import androidx.compose.material3.Card | |
import androidx.compose.material3.CardDefaults | |
import androidx.compose.material3.Scaffold | |
import androidx.compose.material3.Text | |
import androidx.compose.runtime.Composable | |
import androidx.compose.runtime.LaunchedEffect | |
import androidx.compose.runtime.collectAsState | |
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.Alignment | |
import androidx.compose.ui.Modifier | |
import androidx.compose.ui.graphics.Color | |
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.style.TextAlign | |
import androidx.compose.ui.unit.dp | |
import androidx.compose.ui.unit.sp | |
import com.divadventure.R | |
import com.divadventure.ui.AuthTextField | |
import com.divadventure.ui.TopSnackBar | |
import com.divadventure.ui.theme.SystemUIManager | |
import com.divadventure.viewmodel.AuthIntent | |
import com.divadventure.viewmodel.AuthViewModel | |
import com.divadventure.data.navigation.NavigationViewModel | |
import com.divadventure.viewmodel.AuthUiEvent | |
import kotlinx.coroutines.launch | |
@Composable | |
fun OnboardingScreen( | |
navigationViewModel: NavigationViewModel, viewModel: AuthViewModel, padding: PaddingValues | |
) { | |
val state by viewModel.state.collectAsState() | |
SystemUIManager( | |
isDarkThemeForBottom = false, | |
isDarkThemeForStatusBar = false, | |
Color.Transparent, | |
Color.Transparent, | |
hideNavigationBar = !state.navigationBarVisibility, | |
hideStatusBar = !state.statusBarVisibility, | |
onSystemBarsVisibilityChange = { statusbar, navigationBar -> | |
/* | |
viewModel.sendIntent( | |
AuthIntent.MutualIntent.ChangeInsetsVisibility( | |
statusbar, navigationBar | |
) | |
) | |
*/ | |
}) | |
Scaffold( | |
containerColor = Color.White | |
) { innerPadding -> | |
var showTopSnackBar by remember { mutableStateOf(false) } | |
var topSnackBarMessage by remember { mutableStateOf("") } | |
var topSnackBarTitle by remember { mutableStateOf("") } | |
val timer = remember { mutableStateOf<CountDownTimer?>(null) } | |
val coroutineScope = rememberCoroutineScope() | |
var showDialog by remember { mutableStateOf(false) } | |
// Handle UiEvents and SnackBar | |
LaunchedEffect(key1 = true) { | |
viewModel.uiEvent.collect { event -> | |
when (event) { | |
AuthUiEvent.AnimateItem -> {} | |
AuthUiEvent.ShowDialog -> { | |
showDialog = true | |
} | |
is AuthUiEvent.ShowSnackbar -> { | |
topSnackBarMessage = event.message | |
topSnackBarTitle = event.title | |
showTopSnackBar = true | |
} | |
is AuthUiEvent.ExecuteNavigation -> { | |
navigationViewModel.navigate( | |
event.navigationEvent | |
) | |
} | |
} | |
} | |
} | |
// Handle timer and auto-dismiss | |
LaunchedEffect(showTopSnackBar) { | |
if (showTopSnackBar) { | |
timer.value = object : CountDownTimer(3000, 1000) { | |
override fun onTick(millisUntilFinished: Long) {} | |
override fun onFinish() { | |
coroutineScope.launch { | |
showTopSnackBar = false | |
} | |
} | |
}.start() | |
} else { | |
timer.value?.cancel() | |
} | |
} | |
var userName = remember { mutableStateOf("") } | |
var firstName = remember { mutableStateOf("") } | |
var lastName = remember { mutableStateOf("") } | |
var enterButtonColor by remember { | |
mutableStateOf( | |
Color(0xffBFBFBF) | |
) | |
} | |
when (state.onboardState!!.formDataValid) { | |
(true) -> { | |
if (enterButtonColor != Color(0xff30D158)) { | |
enterButtonColor = Color(0xff30D158) | |
} | |
} | |
(false) -> { | |
if (enterButtonColor != Color(0xffBFBFBF)) { | |
enterButtonColor = Color(0xffBFBFBF) | |
} | |
} | |
} | |
Box( | |
modifier = Modifier | |
.fillMaxSize() | |
) { | |
Column( | |
modifier = Modifier | |
.fillMaxSize() | |
.padding(0.dp, 0.dp), | |
horizontalAlignment = Alignment.CenterHorizontally | |
) { | |
Box(modifier = Modifier.weight(0.3f, true)) { | |
TopSnackBar( | |
paddingTop = padding.calculateTopPadding(), | |
title = topSnackBarTitle, | |
message = topSnackBarMessage, | |
show = showTopSnackBar, | |
onDismiss = { showTopSnackBar = false }) | |
Text( | |
modifier = Modifier | |
.align( | |
Alignment.Center | |
) | |
.padding(0.dp, 0.dp), text = "DivAdventure", style = TextStyle( | |
fontFamily = FontFamily(Font(R.font.sf_pro)), | |
textAlign = TextAlign.Center, | |
fontWeight = FontWeight.Bold, | |
fontSize = 32.sp, | |
color = Color(0xff30D158), | |
) | |
) | |
} | |
Column( | |
modifier = Modifier.weight(0.4f, true) | |
) { | |
AuthTextField( | |
modifier = Modifier.padding(0.dp, 0.dp, 0.dp, 40.dp), | |
hint = "UserName", | |
text = userName, | |
onValueChange = { | |
viewModel.sendIntent( | |
AuthIntent.OnboardIntent.OnUserNameChanged( | |
userName = userName.value | |
) | |
) | |
}, | |
explain = "Your user name", | |
essential = true, | |
isPassword = false, | |
isEmail = false, | |
isNormalText = true, | |
) | |
AuthTextField( | |
modifier = Modifier.padding(0.dp, 0.dp, 0.dp, 40.dp), | |
hint = "First Name", | |
text = firstName, | |
onValueChange = { | |
viewModel.sendIntent( | |
AuthIntent.OnboardIntent.OnFirstNameChanged( | |
firstName = firstName.value | |
) | |
) | |
}, | |
explain = "Your Name", | |
essential = true, | |
isPassword = false, | |
isEmail = false, | |
isNormalText = true, | |
) | |
AuthTextField( | |
modifier = Modifier.padding(0.dp, 0.dp, 0.dp, 40.dp), | |
hint = "Last Name", | |
text = lastName, | |
onValueChange = { | |
viewModel.sendIntent( | |
AuthIntent.OnboardIntent.OnLastNameChanged( | |
lastName = lastName.value | |
) | |
) | |
}, | |
explain = "Your Last name", | |
essential = true, | |
isPassword = false, | |
isEmail = false, | |
isNormalText = true, | |
) | |
} | |
Box( | |
modifier = Modifier | |
.weight(0.3f, true) | |
.padding(15.dp, 0.dp) | |
) { | |
Card( | |
colors = CardDefaults.cardColors( | |
containerColor = enterButtonColor, | |
contentColor = Color.White, | |
disabledContainerColor = enterButtonColor, | |
disabledContentColor = Color.White | |
), | |
modifier = Modifier | |
.clickable { | |
viewModel.sendIntent( | |
AuthIntent.OnboardIntent.Onboard( | |
firstName = firstName.value, | |
lastName = lastName.value, | |
userName = userName.value | |
) | |
) | |
} | |
.fillMaxWidth() | |
.padding(0.dp, 0.dp) | |
.height(50.dp), | |
shape = RoundedCornerShape(4.dp), | |
) { | |
Box(modifier = Modifier.fillMaxSize()) { | |
Text( | |
modifier = Modifier.align(Alignment.Center), style = TextStyle( | |
fontSize = 17.sp, | |
fontWeight = FontWeight.Bold, | |
textAlign = TextAlign.Center, | |
), text = "Continue" | |
) | |
} | |
// show forms error | |
Text(state.onboardState!!.error, color = Color.Red) | |
} | |
} | |
} | |
} | |
} | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\ui\screens\ResetPassword.kt | |
```kt | |
package com.divadventure.ui.screens | |
import android.os.CountDownTimer | |
import android.widget.Toast | |
import androidx.compose.animation.AnimatedVisibility | |
import androidx.compose.foundation.Image | |
import androidx.compose.foundation.background | |
import androidx.compose.foundation.clickable | |
import androidx.compose.foundation.isSystemInDarkTheme | |
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.fillMaxSize | |
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.shape.RoundedCornerShape | |
import androidx.compose.material3.Card | |
import androidx.compose.material3.CardDefaults | |
import androidx.compose.material3.Text | |
import androidx.compose.runtime.Composable | |
import androidx.compose.runtime.LaunchedEffect | |
import androidx.compose.runtime.SideEffect | |
import androidx.compose.runtime.collectAsState | |
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.Alignment | |
import androidx.compose.ui.Modifier | |
import androidx.compose.ui.graphics.Color | |
import androidx.compose.ui.graphics.ColorFilter | |
import androidx.compose.ui.graphics.vector.ImageVector | |
import androidx.compose.ui.platform.LocalContext | |
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.style.TextAlign | |
import androidx.compose.ui.unit.dp | |
import androidx.compose.ui.unit.sp | |
import com.divadventure.R | |
import com.divadventure.ui.AuthTextField | |
import com.divadventure.ui.CARD_HEIGHT | |
import com.divadventure.ui.TopSnackBar | |
import com.divadventure.ui.theme.SystemUIManager | |
import com.divadventure.viewmodel.AuthIntent | |
import com.divadventure.viewmodel.AuthViewModel | |
import com.divadventure.data.navigation.NavigationViewModel | |
import com.divadventure.viewmodel.AuthUiEvent | |
import com.google.accompanist.systemuicontroller.rememberSystemUiController | |
import kotlinx.coroutines.launch | |
import timber.log.Timber | |
@Composable | |
fun ResetPassword( | |
navigationViewModel: NavigationViewModel, viewModel: AuthViewModel, padding: PaddingValues | |
) { | |
val state by viewModel.state.collectAsState() | |
// Reset Password Screen | |
SystemUIManager( | |
isDarkThemeForBottom = false, | |
isDarkThemeForStatusBar = false, | |
Color.Transparent, | |
Color.Transparent, | |
hideNavigationBar = !state.navigationBarVisibility, | |
hideStatusBar = !state.statusBarVisibility, | |
onSystemBarsVisibilityChange = { statusbar, navigationBar -> | |
/* | |
viewModel.sendIntent( | |
AuthIntent.MutualIntent.ChangeInsetsVisibility( | |
statusbar, navigationBar | |
) | |
) | |
*/ | |
}) | |
val systemUiController = rememberSystemUiController() | |
val darkTheme = isSystemInDarkTheme() | |
SideEffect { | |
systemUiController.setStatusBarColor( | |
color = Color.White, darkIcons = !darkTheme | |
) | |
systemUiController.setNavigationBarColor( | |
color = Color.White, darkIcons = !darkTheme | |
) | |
} | |
var showError by remember { mutableStateOf(false) } | |
Timber.d("state: $state") | |
val email = remember { | |
mutableStateOf("") | |
} | |
val password = remember { | |
mutableStateOf("") | |
} | |
val passwordConfirmation = remember { | |
mutableStateOf("") | |
} | |
var enterButtonColor by remember { | |
mutableStateOf( | |
Color(0xffBFBFBF) | |
) | |
} | |
var showTopSnackBar by remember { mutableStateOf(false) } | |
var topSnackBarMessage by remember { mutableStateOf("") } | |
var topSnackBarTitle by remember { mutableStateOf("") } | |
val timer = remember { mutableStateOf<CountDownTimer?>(null) } | |
val coroutineScope = rememberCoroutineScope() | |
var showDialog by remember { mutableStateOf(false) } | |
// Handle UiEvents and SnackBar | |
LaunchedEffect(key1 = true) { | |
viewModel.uiEvent.collect { event -> | |
when (event) { | |
AuthUiEvent.AnimateItem -> {} | |
AuthUiEvent.ShowDialog -> { | |
showDialog = true | |
} | |
is AuthUiEvent.ShowSnackbar -> { | |
topSnackBarMessage = event.message | |
topSnackBarTitle = event.title | |
showTopSnackBar = true | |
} | |
is AuthUiEvent.ExecuteNavigation -> { | |
navigationViewModel.navigate( | |
event.navigationEvent | |
) | |
} | |
} | |
} | |
} | |
// Handle timer and auto-dismiss | |
LaunchedEffect(showTopSnackBar) { | |
if (showTopSnackBar) { | |
timer.value = object : CountDownTimer(3000, 1000) { | |
override fun onTick(millisUntilFinished: Long) {} | |
override fun onFinish() { | |
coroutineScope.launch { | |
showTopSnackBar = false | |
} | |
} | |
}.start() | |
} else { | |
timer.value?.cancel() | |
} | |
} | |
when (state.resetPasswordState!!.formDataValid) { | |
true -> { | |
enterButtonColor = Color(0xff30D158) | |
} | |
false -> { | |
enterButtonColor = Color(0xffBFBFBF) | |
} | |
} | |
Box( | |
modifier = Modifier.background(Color.White)/* | |
.padding(padding) | |
*/ | |
) { | |
Column( | |
modifier = Modifier | |
.fillMaxSize() | |
.padding(0.dp, 0.dp), | |
horizontalAlignment = Alignment.CenterHorizontally, | |
verticalArrangement = Arrangement.Center | |
) { | |
Box(modifier = Modifier.weight(0.3f, true)) { | |
TopSnackBar( | |
paddingTop = padding.calculateTopPadding(), | |
title = topSnackBarTitle, | |
message = topSnackBarMessage, | |
show = showTopSnackBar, | |
onDismiss = { showTopSnackBar = false }) | |
Text( | |
modifier = Modifier | |
.align( | |
Alignment.Center | |
) | |
.padding(0.dp, 0.dp), text = "DivAdventure", style = TextStyle( | |
fontFamily = FontFamily(Font(R.font.sf_pro)), | |
textAlign = TextAlign.Center, | |
fontWeight = FontWeight.Bold, | |
fontSize = 32.sp, | |
color = Color(0xff30D158), | |
) | |
) | |
} | |
Column( | |
modifier = Modifier.weight(0.4f, true) | |
) { | |
AuthTextField( | |
modifier = Modifier.padding(0.dp, 0.dp, 0.dp, 40.dp), | |
hint = "Email", | |
text = email, | |
enabled = false, | |
onValueChange = {}, | |
explain = state.resetPasswordState!!.email, | |
essential = false, | |
isEmail = true | |
) | |
AuthTextField( | |
modifier = Modifier.padding(0.dp, 0.dp, 0.dp, 20.dp), | |
hint = "Password", | |
text = password, | |
onValueChange = { | |
viewModel.sendIntent( | |
AuthIntent.ResetPasswordIntent.OnPasswordChanged( | |
password = password.value | |
) | |
) | |
}, | |
explain = "Your Password", | |
essential = true, | |
isPassword = true | |
) | |
AuthTextField( | |
modifier = Modifier.padding(0.dp, 0.dp, 0.dp, 0.dp), | |
hint = "", | |
text = passwordConfirmation, | |
onValueChange = { | |
viewModel.sendIntent( | |
AuthIntent.ResetPasswordIntent.OnConfirmPasswordChanged( | |
passwordConfirmation.value | |
) | |
) | |
}, | |
explain = "Confirm Password", | |
essential = true, | |
isPassword = true | |
) | |
Column( | |
modifier = Modifier.padding(15.dp, 0.dp, 0.dp, 0.dp), | |
verticalArrangement = Arrangement.Center | |
) { | |
AnimatedVisibility( | |
!state.resetPasswordState!!.is8Characters && | |
state.resetPasswordState!!.startedTyping | |
) { | |
Row( | |
modifier = Modifier.padding(0.dp, 10.dp, 0.dp, 0.dp), | |
verticalAlignment = Alignment.CenterVertically, | |
horizontalArrangement = Arrangement.Center | |
) { | |
Image( | |
modifier = Modifier | |
.size(10.dp) | |
.padding(0.dp), | |
contentDescription = "dot", | |
colorFilter = ColorFilter.tint(Color(0xffEA4335)), | |
imageVector = ImageVector.vectorResource( | |
id = R.drawable.ic_dot, | |
), | |
) | |
Text( | |
"Must be at least 8 characters.", | |
style = TextStyle(color = Color(0xffEA4335)) | |
) | |
} | |
} | |
AnimatedVisibility( | |
!state.resetPasswordState!!.passwordsMatch && | |
state.resetPasswordState!!.startedTyping | |
) { | |
Row( | |
verticalAlignment = Alignment.CenterVertically, | |
horizontalArrangement = Arrangement.Center | |
) { | |
Image( | |
modifier = Modifier | |
.size(10.dp) | |
.padding(0.dp), | |
contentDescription = "dot", | |
colorFilter = ColorFilter.tint(Color(0xffEA4335)), | |
imageVector = ImageVector.vectorResource( | |
id = R.drawable.ic_dot, | |
), | |
) | |
Text( | |
"Both passwords must match.", | |
style = TextStyle(color = Color(0xffEA4335)) | |
) | |
} | |
} | |
} | |
} | |
Column( | |
modifier = Modifier | |
.fillMaxWidth() | |
.weight(0.3f, true) | |
.padding(15.dp, 0.dp), | |
horizontalAlignment = Alignment.CenterHorizontally, | |
verticalArrangement = Arrangement.spacedBy(15.dp, Alignment.Top) | |
) { | |
Card( | |
modifier = Modifier | |
.clickable { | |
if (state.resetPasswordState!!.resetClickable) { | |
viewModel.sendIntent( | |
AuthIntent.ResetPasswordIntent.UpdatePassword( | |
password.value, passwordConfirmation.value | |
) | |
) | |
} | |
} | |
.fillMaxWidth() | |
.padding(0.dp, 0.dp) | |
.height(CARD_HEIGHT), | |
colors = CardDefaults.cardColors( | |
containerColor = enterButtonColor, | |
disabledContainerColor = enterButtonColor, | |
contentColor = Color.White, | |
disabledContentColor = Color.White | |
), | |
shape = RoundedCornerShape(4.dp), | |
) { | |
Box(modifier = Modifier.fillMaxSize()) { | |
Text( | |
modifier = Modifier.align(Alignment.Center), style = TextStyle( | |
fontSize = 17.sp, | |
fontWeight = FontWeight.Bold, | |
textAlign = TextAlign.Center, | |
), text = "Update Password" | |
) | |
} | |
// show forms error | |
Text(state.resetPasswordState!!.error, color = Color.Red) | |
} | |
} | |
} | |
if (showError) { | |
Toast.makeText( | |
LocalContext.current, | |
state.resetPasswordState!!.error ?: "An error has occurred!", | |
Toast.LENGTH_SHORT | |
).show() | |
showError = false | |
} | |
} | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\ui\screens\SignUp.kt | |
```kt | |
package com.divadventure.ui.screens | |
import android.os.CountDownTimer | |
import android.widget.Toast | |
import androidx.compose.animation.AnimatedVisibility | |
import androidx.compose.foundation.BorderStroke | |
import androidx.compose.foundation.Image | |
import androidx.compose.foundation.background | |
import androidx.compose.foundation.clickable | |
import androidx.compose.foundation.isSystemInDarkTheme | |
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.fillMaxSize | |
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.shape.RoundedCornerShape | |
import androidx.compose.material3.Card | |
import androidx.compose.material3.CardDefaults | |
import androidx.compose.material3.Text | |
import androidx.compose.runtime.Composable | |
import androidx.compose.runtime.LaunchedEffect | |
import androidx.compose.runtime.SideEffect | |
import androidx.compose.runtime.collectAsState | |
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.Alignment | |
import androidx.compose.ui.Modifier | |
import androidx.compose.ui.graphics.Color | |
import androidx.compose.ui.graphics.ColorFilter | |
import androidx.compose.ui.graphics.vector.ImageVector | |
import androidx.compose.ui.platform.LocalContext | |
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.style.TextAlign | |
import androidx.compose.ui.unit.dp | |
import androidx.compose.ui.unit.sp | |
import com.divadventure.R | |
import com.divadventure.ui.AuthTextField | |
import com.divadventure.ui.CARD_HEIGHT | |
import com.divadventure.ui.TopSnackBar | |
import com.divadventure.ui.theme.SystemUIManager | |
import com.divadventure.viewmodel.AuthIntent | |
import com.divadventure.viewmodel.AuthViewModel | |
import com.divadventure.data.navigation.NavigationViewModel | |
import com.divadventure.viewmodel.AuthUiEvent | |
import com.google.accompanist.systemuicontroller.rememberSystemUiController | |
import kotlinx.coroutines.launch | |
import timber.log.Timber | |
@Composable | |
fun SignUpScreen( | |
navigationViewModel: NavigationViewModel, | |
viewModel: AuthViewModel, | |
padding: PaddingValues | |
) { | |
val state by viewModel.state.collectAsState() | |
SystemUIManager( | |
isDarkThemeForBottom = false, | |
isDarkThemeForStatusBar = false, | |
Color.Transparent, | |
Color.Transparent, | |
hideNavigationBar = !state.navigationBarVisibility, | |
hideStatusBar = !state.statusBarVisibility, | |
onSystemBarsVisibilityChange = { statusbar, navigationBar -> | |
/* | |
viewModel.sendIntent( | |
AuthIntent.MutualIntent.ChangeInsetsVisibility( | |
statusbar, navigationBar | |
) | |
) | |
*/ | |
}) | |
val systemUiController = rememberSystemUiController() | |
val darkTheme = isSystemInDarkTheme() | |
SideEffect { | |
systemUiController.setStatusBarColor( | |
color = Color.White, | |
darkIcons = !darkTheme | |
) | |
systemUiController.setNavigationBarColor( | |
color = Color.White, | |
darkIcons = !darkTheme | |
) | |
} | |
var showError by remember { mutableStateOf(false) } | |
Timber.d("state: $state") | |
val email = remember { | |
mutableStateOf("") | |
} | |
val password = remember { | |
mutableStateOf("") | |
} | |
val passwordConfirmation = remember { | |
mutableStateOf("") | |
} | |
var enterButtonColor by remember { | |
mutableStateOf( | |
Color(0xffBFBFBF) | |
) | |
} | |
var showTopSnackBar by remember { mutableStateOf(false) } | |
var topSnackBarMessage by remember { mutableStateOf("") } | |
var topSnackBarTitle by remember { mutableStateOf("") } | |
val timer = remember { mutableStateOf<CountDownTimer?>(null) } | |
val coroutineScope = rememberCoroutineScope() | |
var showDialog by remember { mutableStateOf(false) } | |
// Handle UiEvents and SnackBar | |
LaunchedEffect(key1 = true) { | |
viewModel.uiEvent.collect { event -> | |
when (event) { | |
AuthUiEvent.AnimateItem -> {} | |
AuthUiEvent.ShowDialog -> { | |
showDialog = true | |
} | |
is AuthUiEvent.ShowSnackbar -> { | |
topSnackBarMessage = event.message | |
topSnackBarTitle = event.title | |
showTopSnackBar = true | |
} | |
is AuthUiEvent.ExecuteNavigation -> { | |
navigationViewModel.navigate( | |
event.navigationEvent | |
) | |
} | |
} | |
} | |
} | |
// Handle timer and auto-dismiss | |
LaunchedEffect(showTopSnackBar) { | |
if (showTopSnackBar) { | |
timer.value = object : CountDownTimer(3000, 1000) { | |
override fun onTick(millisUntilFinished: Long) {} | |
override fun onFinish() { | |
coroutineScope.launch { | |
showTopSnackBar = false | |
} | |
} | |
}.start() | |
} else { | |
timer.value?.cancel() | |
} | |
} | |
when (state.signupState!!.formDataValid) { | |
true -> { | |
enterButtonColor = Color(0xff30D158) | |
} | |
false -> { | |
enterButtonColor = Color(0xffBFBFBF) | |
} | |
} | |
Box( | |
modifier = Modifier | |
.background(Color.White) | |
) { | |
Column( | |
modifier = Modifier | |
.fillMaxSize() | |
.padding(0.dp, 0.dp), | |
horizontalAlignment = Alignment.CenterHorizontally, | |
verticalArrangement = Arrangement.Center | |
) { | |
Box(modifier = Modifier.weight(0.3f, true)) { | |
TopSnackBar( | |
paddingTop = padding.calculateTopPadding(), | |
title = topSnackBarTitle, | |
message = topSnackBarMessage, | |
show = showTopSnackBar, | |
onDismiss = { showTopSnackBar = false } | |
) | |
Text( | |
modifier = Modifier | |
.align( | |
Alignment.Center | |
) | |
.padding(0.dp, 0.dp), | |
text = "DivAdventure", | |
style = TextStyle( | |
fontFamily = FontFamily(Font(R.font.sf_pro)), | |
textAlign = TextAlign.Center, | |
fontWeight = FontWeight.Bold, | |
fontSize = 32.sp, | |
color = Color(0xff30D158), | |
) | |
) | |
} | |
Column( | |
modifier = Modifier.weight(0.4f, true) | |
) { | |
AuthTextField( | |
modifier = Modifier.padding(0.dp, 0.dp, 0.dp, 40.dp), | |
hint = "Email", | |
text = email, | |
onValueChange = { | |
viewModel.sendIntent( | |
AuthIntent.SignupIntent.OnEmailChanged( | |
email = email.value | |
) | |
) | |
}, | |
explain = "Your Email", | |
essential = true, | |
isEmail = true | |
) | |
AuthTextField( | |
modifier = Modifier.padding(0.dp, 0.dp, 0.dp, 20.dp), | |
hint = "Password", | |
text = password, | |
onValueChange = { | |
viewModel.sendIntent( | |
AuthIntent.SignupIntent.OnPasswordChanged( | |
password = password.value | |
) | |
) | |
}, | |
explain = "Your Password", | |
essential = true, | |
isPassword = true | |
) | |
AuthTextField( | |
modifier = Modifier.padding(0.dp, 0.dp, 0.dp, 0.dp), | |
hint = "", | |
text = passwordConfirmation, | |
onValueChange = { | |
viewModel.sendIntent( | |
AuthIntent.SignupIntent.OnPasswordConfirmationChanged( | |
passwordConfirmation = passwordConfirmation.value | |
) | |
) | |
}, | |
explain = "Confirm Password", | |
essential = true, | |
isPassword = true | |
) | |
Column( | |
modifier = Modifier.padding(15.dp, 0.dp, 0.dp, 0.dp), | |
verticalArrangement = Arrangement.Center | |
) { | |
AnimatedVisibility( | |
!state.signupState!!.is8Characters && | |
state.signupState!!.startedTyping | |
) { | |
Row( | |
modifier = Modifier.padding(0.dp, 10.dp, 0.dp, 0.dp), | |
verticalAlignment = Alignment.CenterVertically, | |
horizontalArrangement = Arrangement.Center | |
) { | |
Image( | |
modifier = Modifier | |
.size(10.dp) | |
.padding(0.dp), | |
contentDescription = "dot", | |
colorFilter = ColorFilter.tint(Color(0xffEA4335)), | |
imageVector = ImageVector.vectorResource( | |
id = R.drawable.ic_dot, | |
), | |
) | |
Text( | |
"Must be at least 8 characters.", | |
style = TextStyle(color = Color(0xffEA4335)) | |
) | |
} | |
} | |
AnimatedVisibility( | |
state.signupState!!.startedTyping && !state.signupState!!.passwordsMatch | |
) { | |
Row( | |
verticalAlignment = Alignment.CenterVertically, | |
horizontalArrangement = Arrangement.Center | |
) { | |
Image( | |
modifier = Modifier | |
.size(10.dp) | |
.padding(0.dp), | |
contentDescription = "dot", | |
colorFilter = ColorFilter.tint(Color(0xffEA4335)), | |
imageVector = ImageVector.vectorResource( | |
id = R.drawable.ic_dot, | |
), | |
) | |
Text( | |
"Both passwords must match.", | |
style = TextStyle(color = Color(0xffEA4335)) | |
) | |
} | |
} | |
} | |
} | |
Column( | |
modifier = Modifier | |
.fillMaxWidth() | |
.weight(0.3f, true) | |
.padding(15.dp, 0.dp), | |
horizontalAlignment = Alignment.CenterHorizontally, | |
verticalArrangement = Arrangement.spacedBy(15.dp, Alignment.Top) | |
) { | |
Card( | |
modifier = Modifier | |
.clickable { | |
if (state.signupState!!.signupClickable) { | |
viewModel.sendIntent( | |
AuthIntent.SignupIntent.SignUp( | |
email.value, password.value, passwordConfirmation.value | |
) | |
) | |
} | |
} | |
.fillMaxWidth() | |
.padding(0.dp, 0.dp) | |
.height(CARD_HEIGHT), | |
colors = CardDefaults.cardColors( | |
containerColor = enterButtonColor, | |
disabledContainerColor = enterButtonColor, | |
contentColor = Color.White, | |
disabledContentColor = Color.White | |
), | |
shape = RoundedCornerShape(4.dp), | |
) { | |
Box(modifier = Modifier.fillMaxSize()) { | |
Text( | |
modifier = Modifier.align(Alignment.Center), style = TextStyle( | |
fontSize = 17.sp, | |
fontWeight = FontWeight.Bold, | |
textAlign = TextAlign.Center, | |
), text = "Create Account" | |
) | |
} | |
// show forms error | |
Text(state.signupState!!.error, color = Color.Red) | |
} | |
Card( | |
modifier = Modifier | |
.clickable { | |
viewModel.sendIntent(AuthIntent.SignupIntent.SignUpWithGoogle) | |
} | |
.fillMaxWidth() | |
.padding(0.dp, 0.dp) | |
.height(CARD_HEIGHT), | |
border = BorderStroke(1.dp, Color(0xffBFBFBF)), | |
colors = CardDefaults.cardColors( | |
containerColor = Color.White, | |
disabledContainerColor = Color.White, | |
contentColor = Color.White, | |
disabledContentColor = Color.White | |
), | |
shape = RoundedCornerShape(4.dp), | |
) { | |
Box(modifier = Modifier.fillMaxSize()) { | |
Row( | |
modifier = Modifier.align(Alignment.Center), | |
verticalAlignment = Alignment.CenterVertically | |
) { | |
Image( | |
modifier = Modifier.padding(0.dp, 0.dp, 10.dp, 0.dp), | |
imageVector = ImageVector.vectorResource(R.drawable.ic_google), | |
contentDescription = "google", | |
) | |
Text( | |
modifier = Modifier.padding( | |
10.dp, 0.dp, 0.dp, 0.dp | |
), style = TextStyle( | |
fontSize = 17.sp, | |
fontWeight = FontWeight.Bold, | |
textAlign = TextAlign.Center, | |
color = Color.Black | |
), text = "Sign Up with Google" | |
) | |
} | |
} | |
} | |
Card( | |
modifier = Modifier | |
.clickable {} | |
.fillMaxWidth() | |
.padding(0.dp, 0.dp) | |
.height(CARD_HEIGHT), | |
colors = CardDefaults.cardColors( | |
containerColor = Color(0xff2553B4), | |
disabledContainerColor = Color(0xff2553B4), | |
contentColor = Color.White, | |
disabledContentColor = Color.White | |
), | |
shape = RoundedCornerShape(4.dp), | |
) { | |
Box(modifier = Modifier.fillMaxSize()) { | |
Row( | |
modifier = Modifier.align(Alignment.Center), | |
verticalAlignment = Alignment.CenterVertically | |
) { | |
Image( | |
modifier = Modifier.padding(0.dp, 0.dp, 10.dp, 0.dp), | |
imageVector = ImageVector.vectorResource(R.drawable.ic_facebook), | |
contentDescription = "facebook", | |
alignment = Alignment.BottomCenter | |
) | |
Text( | |
modifier = Modifier.padding( | |
10.dp, 0.dp, 0.dp, 0.dp | |
), style = TextStyle( | |
fontSize = 17.sp, | |
fontWeight = FontWeight.Bold, | |
textAlign = TextAlign.Center, | |
color = Color.White | |
), text = "Sign Up with Facebook" | |
) | |
} | |
} | |
} | |
} | |
} | |
if (showError) { | |
Toast.makeText( | |
LocalContext.current, state.signupState!!.error ?: "An error has occurred!", | |
Toast.LENGTH_SHORT | |
).show() | |
showError = false | |
} | |
} | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\ui\screens\SplashScreen.kt | |
```kt | |
package com.divadventure.ui.screens | |
// In MainActivity.kt (or your activity) | |
import android.os.CountDownTimer | |
import androidx.activity.ComponentActivity | |
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.fillMaxSize | |
import androidx.compose.foundation.layout.fillMaxWidth | |
import androidx.compose.foundation.layout.padding | |
import androidx.compose.foundation.layout.size | |
import androidx.compose.material3.Text | |
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.Alignment | |
import androidx.compose.ui.Modifier | |
import androidx.compose.ui.graphics.Color | |
import androidx.compose.ui.graphics.toArgb | |
import androidx.compose.ui.layout.ContentScale | |
import androidx.compose.ui.platform.LocalView | |
import androidx.compose.ui.res.painterResource | |
import androidx.compose.ui.text.TextStyle | |
import androidx.compose.ui.text.font.FontWeight | |
import androidx.compose.ui.text.style.TextAlign | |
import androidx.compose.ui.unit.dp | |
import androidx.compose.ui.unit.sp | |
import androidx.core.view.WindowInsetsCompat | |
import androidx.core.view.WindowInsetsControllerCompat | |
import coil.compose.rememberAsyncImagePainter | |
import com.airbnb.lottie.compose.LottieAnimation | |
import com.airbnb.lottie.compose.LottieCompositionSpec | |
import com.airbnb.lottie.compose.LottieConstants | |
import com.airbnb.lottie.compose.animateLottieCompositionAsState | |
import com.airbnb.lottie.compose.rememberLottieComposition | |
import com.divadventure.R | |
import com.divadventure.viewmodel.AuthIntent | |
import com.divadventure.viewmodel.AuthViewModel | |
import com.divadventure.data.navigation.NavigationViewModel | |
import com.divadventure.viewmodel.AuthUiEvent | |
import kotlinx.coroutines.launch | |
/*@Composable | |
fun Splash( | |
) { | |
SplashScreen() | |
} | |
@Preview( | |
showBackground = true, device = Devices.PIXEL | |
)*/ | |
@Composable | |
fun SplashScreen( | |
navigationViewModel: NavigationViewModel, | |
viewModel: AuthViewModel | |
) { | |
// This method will change the color of the status bar to the | |
// color that you prefer | |
val view = LocalView.current | |
val window = (view.context as ComponentActivity).window | |
window.statusBarColor = Color.Transparent.toArgb() | |
window.statusBarColor = Color.Transparent.toArgb() | |
val windowInsetsController = WindowInsetsControllerCompat(window, view) | |
windowInsetsController.hide(WindowInsetsCompat.Type.statusBars()) | |
windowInsetsController.hide(WindowInsetsCompat.Type.navigationBars()) | |
var showTopSnackBar by remember { mutableStateOf(false) } | |
var topSnackBarMessage by remember { mutableStateOf("") } | |
var topSnackBarTitle by remember { mutableStateOf("") } | |
val timer = remember { mutableStateOf<CountDownTimer?>(null) } | |
val coroutineScope = rememberCoroutineScope() | |
var showDialog by remember { mutableStateOf(false) } | |
// Handle UiEvents and SnackBar | |
LaunchedEffect(key1 = true) { | |
viewModel.uiEvent.collect { event -> | |
when (event) { | |
AuthUiEvent.AnimateItem -> {} | |
AuthUiEvent.ShowDialog -> { | |
showDialog = true | |
} | |
is AuthUiEvent.ShowSnackbar -> { | |
topSnackBarMessage = event.message | |
topSnackBarTitle = event.title | |
showTopSnackBar = true | |
} | |
is AuthUiEvent.ExecuteNavigation -> { | |
navigationViewModel.navigate( | |
event.navigationEvent | |
) | |
} | |
} | |
} | |
} | |
// Handle timer and auto-dismiss | |
LaunchedEffect(showTopSnackBar) { | |
if (showTopSnackBar) { | |
timer.value = object : CountDownTimer(3000, 1000) { | |
override fun onTick(millisUntilFinished: Long) {} | |
override fun onFinish() { | |
coroutineScope.launch { | |
showTopSnackBar = false | |
} | |
} | |
}.start() | |
} else { | |
timer.value?.cancel() | |
} | |
} | |
LaunchedEffect(key1 = true) { | |
//delay(2000) | |
viewModel.sendIntent(AuthIntent.SplashIntent.CheckDecision) | |
} | |
Box( | |
modifier = Modifier | |
.fillMaxWidth() | |
.fillMaxSize() | |
.background( | |
color = Color(0xFF30D158) | |
) | |
) { | |
Image( | |
painter = rememberAsyncImagePainter(model = R.drawable.background), | |
contentDescription = "Splash Screen", | |
modifier = Modifier.fillMaxSize(), | |
contentScale = ContentScale.FillBounds, | |
) | |
Loader(Modifier.align(Alignment.Center)) | |
Column( | |
modifier = Modifier.fillMaxSize(), | |
horizontalAlignment = Alignment.CenterHorizontally | |
) { | |
Box(modifier = Modifier.weight(0.5f, true)) { | |
Text( | |
modifier = Modifier.padding(0.dp, 200.dp, 0.dp, 0.dp), | |
text = "DivAdventure", | |
textAlign = TextAlign.Center, | |
style = TextStyle( | |
color = Color.White, | |
fontWeight = FontWeight.Bold | |
), | |
fontSize = 24.sp | |
) | |
} | |
Box( | |
modifier = Modifier.weight(0.5f, true) | |
) { | |
Image( | |
modifier = Modifier | |
.padding(0.dp, 250.dp, 0.dp, 0.dp) | |
.align( | |
Alignment.Center | |
), | |
contentDescription = "Logo", | |
contentScale = ContentScale.FillBounds, | |
painter = painterResource(id = R.drawable.divnotes_logo) | |
) | |
} | |
} | |
} | |
} | |
@Composable | |
fun Loader(modifier: Modifier) { | |
val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.loading)) | |
val progress by animateLottieCompositionAsState( | |
composition = composition, | |
iterations = LottieConstants.IterateForever | |
) | |
LottieAnimation( | |
modifier = modifier.size(100.dp), | |
composition = composition, | |
progress = { progress }, | |
) | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\ui\screens\UserDataManager.kt | |
```kt | |
package com.divadventure.ui.screens | |
import com.divadventure.di.UserPrefs.KEY_ADVENTURES_PRIVACY | |
import com.divadventure.di.UserPrefs.KEY_AVATAR | |
import com.divadventure.di.UserPrefs.KEY_BIO | |
import com.divadventure.di.UserPrefs.KEY_BIO_PRIVACY | |
import com.divadventure.di.UserPrefs.KEY_BIRTH_DATE | |
import com.divadventure.di.UserPrefs.KEY_BIRTH_DATE_PRIVACY | |
import com.divadventure.di.UserPrefs.KEY_EMAIL | |
import com.divadventure.di.UserPrefs.KEY_FIRST_NAME | |
import com.divadventure.di.UserPrefs.KEY_FRIENDS_PRIVACY | |
import com.divadventure.di.UserPrefs.KEY_ID | |
import com.divadventure.di.UserPrefs.KEY_LAST_NAME | |
import com.divadventure.di.UserPrefs.KEY_LOCATION | |
import com.divadventure.di.UserPrefs.KEY_LOCATION_PRIVACY | |
import com.divadventure.di.UserPrefs.KEY_PLATFORM | |
import com.divadventure.di.UserPrefs.KEY_REFRESH_TOKEN | |
import com.divadventure.di.UserPrefs.KEY_REFRESH_TOKEN_EXPIRES_AT | |
import com.divadventure.di.UserPrefs.KEY_TOKEN | |
import com.divadventure.di.UserPrefs.KEY_TOKEN_EXPIRES_AT | |
import com.divadventure.di.UserPrefs.KEY_USERNAME | |
import com.divadventure.di.SharedPrefs | |
import com.divadventure.domain.models.SignUpResponse | |
class UserDataManager(private val sharedPrefs: SharedPrefs) { | |
fun savePrimaryData(body: SignUpResponse) { | |
val user = body.data | |
val privacy = user.privacySettings | |
val token = user.currentAccessToken | |
val location = user.location | |
val keyValuePairs = mapOf( | |
KEY_ID to user.id, | |
KEY_AVATAR to user.avatar, | |
KEY_EMAIL to user.email, | |
KEY_FIRST_NAME to user.firstName, | |
KEY_LAST_NAME to user.lastName, | |
KEY_USERNAME to user.username, | |
KEY_BIRTH_DATE to user.birthdate, | |
KEY_BIO to user.bio, | |
/* | |
KEY_LOCATION to location.id, | |
*/ | |
KEY_BIO_PRIVACY to privacy.bio, | |
KEY_LOCATION_PRIVACY to privacy.bio, | |
KEY_BIRTH_DATE_PRIVACY to privacy.birthdate, | |
KEY_FRIENDS_PRIVACY to privacy.friends, | |
KEY_ADVENTURES_PRIVACY to privacy.adventures, | |
KEY_TOKEN to token.token, | |
KEY_REFRESH_TOKEN to token.refreshToken, | |
KEY_PLATFORM to token.platform, | |
KEY_TOKEN_EXPIRES_AT to token.expiresAt, | |
KEY_REFRESH_TOKEN_EXPIRES_AT to token.refreshTokenExpiresAt | |
) | |
keyValuePairs.forEach { (key, value) -> | |
sharedPrefs.setString(key, value) | |
} | |
} | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\ui\screens\Verification.kt | |
```kt | |
package com.divadventure.ui.screens | |
import android.os.CountDownTimer | |
import androidx.compose.foundation.background | |
import androidx.compose.foundation.clickable | |
import androidx.compose.foundation.isSystemInDarkTheme | |
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.fillMaxSize | |
import androidx.compose.foundation.layout.fillMaxWidth | |
import androidx.compose.foundation.layout.height | |
import androidx.compose.foundation.layout.padding | |
import androidx.compose.foundation.layout.width | |
import androidx.compose.foundation.shape.RoundedCornerShape | |
import androidx.compose.material3.Card | |
import androidx.compose.material3.CardDefaults | |
import androidx.compose.material3.Scaffold | |
import androidx.compose.material3.Text | |
import androidx.compose.runtime.Composable | |
import androidx.compose.runtime.DisposableEffect | |
import androidx.compose.runtime.LaunchedEffect | |
import androidx.compose.runtime.SideEffect | |
import androidx.compose.runtime.collectAsState | |
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.Alignment | |
import androidx.compose.ui.Modifier | |
import androidx.compose.ui.focus.FocusRequester | |
import androidx.compose.ui.graphics.Color | |
import androidx.compose.ui.text.SpanStyle | |
import androidx.compose.ui.text.TextStyle | |
import androidx.compose.ui.text.buildAnnotatedString | |
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.style.TextAlign | |
import androidx.compose.ui.text.withStyle | |
import androidx.compose.ui.unit.dp | |
import androidx.compose.ui.unit.sp | |
import com.composeuisuite.ohteepee.OhTeePeeDefaults | |
import com.composeuisuite.ohteepee.OhTeePeeInput | |
import com.divadventure.R | |
import com.divadventure.di.SharedPrefs | |
import com.divadventure.ui.TopSnackBar | |
import com.divadventure.ui.theme.SystemUIManager | |
import com.divadventure.viewmodel.AuthIntent | |
import com.divadventure.viewmodel.AuthViewModel | |
import com.divadventure.data.navigation.NavigationViewModel | |
import com.divadventure.viewmodel.AuthUiEvent | |
import com.google.accompanist.systemuicontroller.rememberSystemUiController | |
import kotlinx.coroutines.launch | |
import timber.log.Timber | |
@Composable | |
fun VerificationScreen( | |
viewModel: AuthViewModel, | |
navigationViewModel: NavigationViewModel, | |
padding: PaddingValues, | |
sharedPrefs: SharedPrefs | |
) { | |
val state by viewModel.state.collectAsState() | |
SystemUIManager( | |
isDarkThemeForBottom = false, | |
isDarkThemeForStatusBar = false, | |
Color.Transparent, | |
Color.Transparent, | |
hideNavigationBar = !state.navigationBarVisibility, | |
hideStatusBar = !state.statusBarVisibility, | |
onSystemBarsVisibilityChange = { statusbar, navigationBar -> | |
// viewModel.sendIntent(AuthIntent.MutualIntent.ChangeInsetsVisibility(statusbar, navigationBar)) | |
}) | |
var paddingValues by remember { mutableStateOf(padding) } | |
val systemUiController = rememberSystemUiController() | |
val darkTheme = isSystemInDarkTheme() | |
SideEffect { | |
systemUiController.setStatusBarColor( | |
color = Color.White, darkIcons = !darkTheme | |
) | |
systemUiController.setNavigationBarColor( | |
color = Color(0xffefeff4), darkIcons = !darkTheme | |
) | |
} | |
Scaffold( | |
containerColor = Color(0xffefeff4) | |
) { paddingValues: PaddingValues -> | |
var otpString by remember { mutableStateOf("") } | |
val email = remember { | |
mutableStateOf("") | |
} | |
remember { | |
FocusRequester() | |
} | |
var revisionEmailButtonColor by remember { | |
mutableStateOf( | |
Color(0xffBFBFBF) | |
) | |
} | |
var continueButtonColor by remember { | |
mutableStateOf( | |
Color(0xffBFBFBF) | |
) | |
} | |
var resendCodeColor by remember { | |
mutableStateOf( | |
Color(0xffBFBFBF) | |
) | |
} | |
revisionEmailButtonColor = if (state.verificationState!!.permitEmailRevision) { | |
Color(0xff30D158) | |
} else { | |
Color(0xffBFBFBF) | |
} | |
continueButtonColor = if (state.verificationState!!.isOtpCorrect) { | |
Color(0xff30D158) | |
} else { | |
Color(0xffBFBFBF) | |
} | |
resendCodeColor = if (state.verificationState!!.otpRemainTime == 0L) { | |
Color(0xff007AFF) | |
} else { | |
Color(0xffBFBFBF) | |
} | |
// this config will be used for each cell | |
val defaultCellConfig = OhTeePeeDefaults.cellConfiguration( | |
borderColor = Color.LightGray, | |
borderWidth = 0.dp, | |
shape = RoundedCornerShape(8.dp), | |
textStyle = TextStyle( | |
color = Color.Black | |
) | |
) | |
val filledCellConfig = OhTeePeeDefaults.cellConfiguration( | |
borderColor = Color(0xff007AFF), | |
borderWidth = 1.dp, | |
shape = RoundedCornerShape(8.dp), | |
textStyle = TextStyle( | |
color = Color.Black | |
) | |
) | |
var countdownTime by remember { mutableStateOf(120L) } | |
Box( | |
modifier = Modifier.fillMaxSize() | |
) { | |
DisposableEffect(Unit) { | |
val timer = object : CountDownTimer(120000, 1000) { | |
override fun onTick(millisUntilFinished: Long) { | |
countdownTime = millisUntilFinished / 1000 | |
viewModel.sendIntent( | |
AuthIntent.VerificationIntent.UpdateTimer(countdownTime) | |
) | |
} | |
override fun onFinish() { | |
countdownTime = 0 | |
viewModel.sendIntent( | |
AuthIntent.VerificationIntent.UpdateTimer(0) | |
) | |
} | |
} | |
timer.start() | |
onDispose { | |
timer.cancel() | |
} | |
} | |
var showTopSnackBar by remember { mutableStateOf(false) } | |
var topSnackBarMessage by remember { mutableStateOf("") } | |
var topSnackBarTitle by remember { mutableStateOf("") } | |
val timer = remember { mutableStateOf<CountDownTimer?>(null) } | |
val coroutineScope = rememberCoroutineScope() | |
var showDialog by remember { mutableStateOf(false) } | |
// Handle UiEvents and SnackBar | |
LaunchedEffect(key1 = true) { | |
viewModel.uiEvent.collect { event -> | |
when (event) { | |
AuthUiEvent.AnimateItem -> {} | |
AuthUiEvent.ShowDialog -> { | |
showDialog = true | |
} | |
is AuthUiEvent.ShowSnackbar -> { | |
topSnackBarMessage = event.message | |
topSnackBarTitle = event.title | |
showTopSnackBar = true | |
} | |
is AuthUiEvent.ExecuteNavigation -> { | |
navigationViewModel.navigate( | |
event.navigationEvent | |
) | |
} | |
} | |
} | |
} | |
// Handle timer and auto-dismiss | |
LaunchedEffect(showTopSnackBar) { | |
if (showTopSnackBar) { | |
timer.value = object : CountDownTimer(3000, 1000) { | |
override fun onTick(millisUntilFinished: Long) {} | |
override fun onFinish() { | |
coroutineScope.launch { | |
showTopSnackBar = false | |
} | |
} | |
}.start() | |
} else { | |
timer.value?.cancel() | |
} | |
} | |
TopSnackBar( | |
paddingTop = paddingValues.calculateTopPadding(), | |
title = topSnackBarTitle, | |
message = topSnackBarMessage, | |
show = showTopSnackBar, | |
onDismiss = { showTopSnackBar = false }) | |
Column( | |
modifier = Modifier, horizontalAlignment = Alignment.CenterHorizontally | |
) { | |
Box( | |
modifier = Modifier | |
.background(color = Color.White) | |
.padding( | |
50.dp, | |
top = paddingValues.calculateTopPadding() + 15.dp, | |
bottom = 15.dp | |
) | |
.fillMaxWidth(), | |
contentAlignment = Alignment.CenterStart | |
) { | |
Text( | |
modifier = Modifier.align(Alignment.CenterStart), | |
text = "Verification Code", | |
style = TextStyle( | |
textAlign = TextAlign.Left, | |
color = Color(0xff1C1C1E), | |
fontSize = 20.sp, | |
fontFamily = FontFamily(Font(R.font.sf_pro)), | |
fontWeight = FontWeight.Bold | |
) | |
) | |
} | |
Box( | |
modifier = Modifier | |
.padding(30.dp) | |
.fillMaxWidth() | |
) { | |
Column(modifier = Modifier.fillMaxWidth()) { | |
Text( | |
modifier = Modifier | |
.padding(0.dp, 5.dp) | |
.fillMaxWidth(), | |
text = "A verification code has been sent to", | |
style = TextStyle( | |
color = Color.Black, | |
textAlign = TextAlign.Center, | |
fontFamily = FontFamily(Font(R.font.sf_pro)) | |
) | |
) | |
Text( | |
modifier = Modifier.fillMaxWidth(), | |
text = state.verificationState?.email ?: "", | |
style = TextStyle( | |
color = Color.Black, | |
fontFamily = FontFamily(Font(R.font.sf_pro)), | |
fontSize = 16.sp, | |
textAlign = TextAlign.Center, | |
fontWeight = FontWeight.Bold | |
) | |
) | |
} | |
} | |
Text( | |
modifier = Modifier | |
.padding(30.dp, 0.dp) | |
.fillMaxWidth(), | |
text = buildAnnotatedString { | |
val verificationState = state.verificationState | |
append( | |
if (verificationState?.otpRemainTime ?: 0 > 0) "Please check your inbox and enter the verification code below to verify your email address. The code will expire in " | |
else "The time has been finished" | |
) | |
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) { | |
append( | |
if (verificationState?.otpRemainTime ?: 0 > 0) "${verificationState?.otpRemainTime} seconds" else "" | |
) | |
} | |
}, | |
style = TextStyle( | |
color = Color.Black, | |
fontFamily = FontFamily(Font(R.font.sf_pro)) | |
), | |
textAlign = TextAlign.Center, | |
) | |
Box(modifier = Modifier.fillMaxWidth()) { | |
OhTeePeeInput( | |
modifier = Modifier | |
.align(Alignment.Center) | |
.padding(0.dp, 20.dp), | |
value = otpString, | |
onValueChange = { newValue: String, isValid: Boolean -> | |
otpString = newValue | |
viewModel.sendIntent( | |
AuthIntent.VerificationIntent.OnOtpChanged( | |
otpString.trim() | |
) | |
) | |
Timber.d("OTP: $newValue") | |
}, | |
autoFocusByDefault = true, | |
horizontalArrangement = Arrangement.spacedBy(1.dp), | |
configurations = OhTeePeeDefaults.inputConfiguration( | |
cellsCount = 6, | |
cellModifier = Modifier | |
.width(46.dp) | |
.height(54.dp), | |
activeCellConfig = filledCellConfig, | |
emptyCellConfig = defaultCellConfig, | |
filledCellConfig = filledCellConfig, | |
), | |
) | |
} | |
Column( | |
modifier = Modifier | |
.fillMaxWidth() | |
.padding(30.dp, 0.dp), | |
horizontalAlignment = Alignment.CenterHorizontally, | |
verticalArrangement = Arrangement.spacedBy(0.dp, Alignment.CenterVertically), | |
) { | |
Card( | |
modifier = Modifier | |
.clickable { | |
viewModel.sendIntent( | |
AuthIntent.VerificationIntent.OnOtpVerifyPressed( | |
otp = otpString | |
) | |
) | |
} | |
.fillMaxWidth() | |
.padding(0.dp, 0.dp) | |
.height(50.dp), | |
colors = CardDefaults.cardColors( | |
containerColor = Color(0xff30D158), | |
disabledContainerColor = Color(0xff30D158), | |
contentColor = Color.White, | |
disabledContentColor = Color.White | |
), | |
shape = RoundedCornerShape(4.dp), | |
) { | |
Box(modifier = Modifier.fillMaxSize()) { | |
Text( | |
modifier = Modifier.align(Alignment.Center), style = TextStyle( | |
fontSize = 17.sp, | |
fontWeight = FontWeight.Bold, | |
textAlign = TextAlign.Center, | |
), text = "Verify" | |
) | |
} | |
// show forms error | |
} | |
Row( | |
modifier = Modifier | |
.fillMaxWidth() | |
.padding(0.dp, 10.dp) | |
) { | |
Text(modifier = if (state.verificationState!!.otpRemainTime == 0L) Modifier.clickable { | |
viewModel.sendIntent( | |
AuthIntent.VerificationIntent.ResendCode | |
) | |
} else Modifier, | |
text = "Resend Code", | |
fontSize = 17.sp, | |
fontFamily = FontFamily(Font(R.font.sf_pro)), | |
style = TextStyle( | |
color = resendCodeColor | |
), | |
textAlign = TextAlign.Left) | |
Spacer(modifier = Modifier.weight(1f, true)) | |
Text( | |
modifier = Modifier.clickable { | |
viewModel.sendIntent( | |
AuthIntent.VerificationIntent.GotoChangeEmail( | |
state.verificationState?.email ?: "" | |
) | |
) | |
}, | |
text = "Change email", | |
fontSize = 17.sp, | |
fontFamily = FontFamily(Font(R.font.sf_pro)), | |
style = TextStyle( | |
color = Color(0xff007AFF), textAlign = TextAlign.Right | |
) | |
) | |
} | |
} | |
} | |
} | |
} | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\ui\screens\VerificationResendEmail.kt | |
```kt | |
package com.divadventure.ui.screens | |
/* | |
@Composable | |
fun VerificationResendEmail( | |
viewModel: AuthViewModel, | |
navigationViewModel: NavigationViewModel, | |
padding: PaddingValues | |
) { | |
SystemUIManager( | |
isDarkThemeForBottom = false, | |
isDarkThemeForStatusBar = false, | |
Color.Transparent, | |
Color.Transparent | |
) | |
val systemUiController = rememberSystemUiController() | |
val darkTheme = isSystemInDarkTheme() | |
SideEffect { | |
systemUiController.setStatusBarColor( | |
color = Color.White, | |
darkIcons = !darkTheme | |
) | |
systemUiController.setNavigationBarColor( | |
color = Color(0xffefeff4), | |
darkIcons = !darkTheme | |
) | |
} | |
val state by viewModel.state.collectAsState() | |
Box( | |
modifier = Modifier.background(color = Color(0xffefeff4)) | |
) { | |
var showTopSnackBar by remember { mutableStateOf(false) } | |
var topSnackBarMessage by remember { mutableStateOf("") } | |
var topSnackBarTitle by remember { mutableStateOf("") } | |
val timer = remember { mutableStateOf<CountDownTimer?>(null) } | |
val coroutineScope = rememberCoroutineScope() | |
var showDialog by remember { mutableStateOf(false) } | |
// Handle UiEvents and SnackBar | |
LaunchedEffect(key1 = true) { | |
viewModel.uiEvent.collect { event -> | |
when (event) { | |
UiEvent.AnimateItem -> {} | |
UiEvent.ShowDialog -> { | |
showDialog = true | |
} | |
is UiEvent.ShowSnackbar -> { | |
topSnackBarMessage = event.message | |
topSnackBarTitle = event.title | |
showTopSnackBar = true | |
} | |
is UiEvent.NavigateToNextScreen -> { | |
navigationViewModel.navigate( | |
event.navigationEvent | |
) | |
} | |
} | |
} | |
} | |
TopSnackBar( | |
paddingTop = padding.calculateTopPadding(), | |
title = topSnackBarTitle, | |
message = topSnackBarMessage, | |
show = showTopSnackBar, | |
onDismiss = { showTopSnackBar = false }) | |
// Handle timer and auto-dismiss | |
LaunchedEffect(showTopSnackBar) { | |
if (showTopSnackBar) { | |
timer.value = object : CountDownTimer(3000, 1000) { | |
override fun onTick(millisUntilFinished: Long) {} | |
override fun onFinish() { | |
coroutineScope.launch { | |
showTopSnackBar = false | |
} | |
} | |
}.start() | |
} else { | |
timer.value?.cancel() | |
} | |
} | |
var email by remember { | |
mutableStateOf(state.verificationResendEmailState!!.email) | |
} | |
val focusRequester = remember { | |
FocusRequester() | |
} | |
var revisionEmailButtonColor by remember { | |
mutableStateOf( | |
Color(0xffBFBFBF) | |
) | |
} | |
var continueButtonColor by remember { | |
mutableStateOf( | |
Color(0xffBFBFBF) | |
) | |
} | |
when (state.verificationState!!.permitEmailRevision) { | |
(true) -> { | |
if (revisionEmailButtonColor != Color(0xff30D158)) { | |
revisionEmailButtonColor = Color(0xff30D158) | |
} | |
} | |
(false) -> { | |
if (revisionEmailButtonColor != Color(0xffBFBFBF)) { | |
revisionEmailButtonColor = Color(0xffBFBFBF) | |
} | |
} | |
} | |
when (state.verificationState!!.isOtpCorrect) { | |
true -> { | |
if (continueButtonColor != Color(0xff30D158)) { | |
continueButtonColor = Color(0xff30D158) | |
} | |
} | |
false -> { | |
if (continueButtonColor != Color(0xffBFBFBF)) { | |
continueButtonColor = Color(0xffBFBFBF) | |
} | |
} | |
} | |
Box( | |
modifier = Modifier.fillMaxSize() | |
) { | |
Column( | |
modifier = Modifier.padding(0.dp, 0.dp), | |
horizontalAlignment = Alignment.CenterHorizontally | |
) { | |
Box( | |
modifier = Modifier | |
.background(color = Color.White) | |
.padding(50.dp, 32.dp) | |
.fillMaxWidth() | |
) { | |
Text( | |
"Email Verification", style = TextStyle( | |
textAlign = TextAlign.Left, | |
color = Color(0xff1C1C1E), | |
fontSize = 20.sp, | |
fontFamily = FontFamily(Font(R.font.sf_pro)), | |
fontWeight = FontWeight.Bold | |
) | |
) | |
} | |
Card( | |
modifier = Modifier | |
.fillMaxWidth() | |
.padding(30.dp, 20.dp) | |
.height(50.dp), | |
colors = CardDefaults.cardColors( | |
containerColor = Color.White, | |
disabledContainerColor = Color.White, | |
contentColor = Color(0xff1C1C1E), | |
disabledContentColor = Color(0xff1C1C1E) | |
) | |
) { | |
Box(modifier = Modifier.fillMaxSize()) { | |
BasicTextField( | |
modifier = Modifier | |
.fillMaxWidth() | |
.align(Alignment.Center) | |
.padding(15.dp, 0.dp), | |
textStyle = TextStyle(fontSize = 17.sp, textAlign = TextAlign.Left), | |
value = email, | |
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions( | |
keyboardType = KeyboardType.Email | |
), | |
onValueChange = { value: String -> | |
email = value | |
}) | |
} | |
} | |
Column( | |
modifier = Modifier | |
.fillMaxWidth() | |
.padding(30.dp, 0.dp), | |
horizontalAlignment = Alignment.CenterHorizontally, | |
verticalArrangement = Arrangement.spacedBy(15.dp, Alignment.CenterVertically), | |
) { | |
Card( | |
modifier = Modifier | |
.clickable { | |
Timber.d("Verification: OTP is incorrect") | |
viewModel.sendIntent( | |
AuthIntent.VerificationResendEmailIntent.ChangeEmail( | |
) | |
) | |
} | |
.fillMaxWidth() | |
.padding(0.dp, 0.dp) | |
.height(50.dp), | |
colors = CardDefaults.cardColors( | |
containerColor = Color(0xff30D158), | |
disabledContainerColor = Color(0xff30D158), | |
contentColor = Color.White, | |
disabledContentColor = Color.White | |
), | |
shape = RoundedCornerShape(4.dp), | |
) { | |
Box(modifier = Modifier.fillMaxSize()) { | |
Text( | |
modifier = Modifier.align(Alignment.Center), style = TextStyle( | |
fontSize = 17.sp, | |
fontWeight = FontWeight.Bold, | |
textAlign = TextAlign.Center, | |
), text = "Change Email" | |
) | |
} | |
// show forms error | |
} | |
Card( | |
modifier = Modifier | |
.clickable { | |
viewModel.sendIntent( | |
AuthIntent.VerificationResendEmailIntent.ResendCode | |
) | |
} | |
.fillMaxWidth() | |
.padding(0.dp, 0.dp) | |
.height(50.dp), | |
colors = CardDefaults.cardColors( | |
containerColor = Color.White, | |
disabledContainerColor = Color.White, | |
contentColor = Color.White, | |
disabledContentColor = Color.White | |
), | |
shape = RoundedCornerShape(4.dp), | |
) { | |
Box(modifier = Modifier.fillMaxSize()) { | |
Text( | |
modifier = Modifier.align(Alignment.Center), style = TextStyle( | |
fontSize = 17.sp, | |
color = Color(0xff007AFF), | |
fontWeight = FontWeight.Bold, | |
textAlign = TextAlign.Center, | |
), text = "Resend Code" | |
) | |
} | |
// show forms error | |
} | |
Card( | |
modifier = Modifier | |
.clickable { | |
viewModel.sendIntent( | |
AuthIntent.VerificationResendEmailIntent.BackToLogin | |
) | |
} | |
.fillMaxWidth() | |
.padding(0.dp, 0.dp) | |
.height(50.dp), | |
colors = CardDefaults.cardColors( | |
containerColor = Color.White, | |
disabledContainerColor = Color.White, | |
contentColor = Color.White, | |
disabledContentColor = Color.White | |
), | |
shape = RoundedCornerShape(4.dp), | |
) { | |
Box(modifier = Modifier.fillMaxSize()) { | |
Text( | |
modifier = Modifier.align(Alignment.Center), style = TextStyle( | |
fontSize = 17.sp, | |
color = Color(0xff007AFF), | |
fontWeight = FontWeight.Bold, | |
textAlign = TextAlign.Center, | |
), text = "Back To Login" | |
) | |
} | |
// show forms error | |
} | |
} | |
} | |
} | |
} | |
} | |
*/ | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\ui\theme\Color.kt | |
```kt | |
package com.divadventure.ui.theme | |
import androidx.compose.ui.graphics.Color | |
val Purple80 = Color(0xFFD0BCFF) | |
val PurpleGrey80 = Color(0xFFCCC2DC) | |
val Pink80 = Color(0xFFEFB8C8) | |
val Purple40 = Color(0xFF6650a4) | |
val PurpleGrey40 = Color(0xFF625b71) | |
val Pink40 = Color(0xFF7D5260) | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\ui\theme\Theme.kt | |
```kt | |
package com.divadventure.ui.theme | |
import android.os.Build | |
import android.view.View | |
import android.view.WindowInsetsController | |
import androidx.activity.ComponentActivity | |
import androidx.compose.foundation.isSystemInDarkTheme | |
import androidx.compose.material3.MaterialTheme | |
import androidx.compose.material3.darkColorScheme | |
import androidx.compose.material3.dynamicDarkColorScheme | |
import androidx.compose.material3.dynamicLightColorScheme | |
import androidx.compose.material3.lightColorScheme | |
import androidx.compose.runtime.Composable | |
import androidx.compose.ui.graphics.Color | |
import androidx.compose.ui.graphics.toArgb | |
import androidx.compose.ui.platform.LocalContext | |
import androidx.compose.ui.platform.LocalView | |
import androidx.core.view.WindowInsetsCompat | |
import androidx.core.view.WindowInsetsControllerCompat | |
private val DarkColorScheme = darkColorScheme( | |
primary = Purple80, | |
secondary = PurpleGrey80, | |
tertiary = Pink80 | |
) | |
private val LightColorScheme = lightColorScheme( | |
primary = Purple40, | |
secondary = PurpleGrey40, | |
tertiary = Pink40 | |
/* Other default colors to override | |
background = Color(0xFFFFFBFE), | |
surface = Color(0xFFFFFBFE), | |
onPrimary = Color.White, | |
onSecondary = Color.White, | |
onTertiary = Color.White, | |
onBackground = Color(0xFF1C1B1F), | |
onSurface = Color(0xFF1C1B1F), | |
*/ | |
) | |
@Composable | |
fun DivAdventureTheme( | |
darkTheme: Boolean = isSystemInDarkTheme(), | |
// Dynamic color is available on Android 12+ | |
dynamicColor: Boolean = true, | |
content: @Composable () -> Unit | |
) { | |
val colorScheme = when { | |
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { | |
val context = LocalContext.current | |
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) | |
} | |
darkTheme -> LightColorScheme//DarkColorScheme | |
else -> LightColorScheme | |
} | |
MaterialTheme( | |
colorScheme = colorScheme, | |
typography = Typography, | |
content = content | |
) | |
} | |
@Composable | |
fun SystemUIManager( | |
isDarkThemeForBottom: Boolean, | |
isDarkThemeForStatusBar: Boolean, | |
statusBarColor: Color, | |
navigationBarColor: Color, | |
hideStatusBar: Boolean = true, | |
hideNavigationBar: Boolean = true, | |
onSystemBarsVisibilityChange: (Boolean, Boolean) -> Unit | |
) { | |
var isProgrammaticChange = false // Flag to track programmatic changes | |
val view = LocalView.current | |
val window = (view.context as ComponentActivity).window | |
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { | |
window.statusBarColor = statusBarColor.toArgb() | |
window.navigationBarColor = navigationBarColor.toArgb() | |
} | |
val windowInsetsController = WindowInsetsControllerCompat(window, view) | |
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { | |
window.statusBarColor = statusBarColor.toArgb() | |
window.navigationBarColor = navigationBarColor.toArgb() | |
view.setOnApplyWindowInsetsListener { _, insets -> | |
val isStatusBarVisible = insets.isVisible(WindowInsetsCompat.Type.statusBars()) | |
val isNavigationBarVisible = insets.isVisible(WindowInsetsCompat.Type.navigationBars()) | |
if (!isProgrammaticChange) { | |
onSystemBarsVisibilityChange(isStatusBarVisible, isNavigationBarVisible) | |
} | |
isProgrammaticChange = false // Reset the flag | |
insets | |
} | |
windowInsetsController.isAppearanceLightStatusBars = !isDarkThemeForStatusBar | |
windowInsetsController.isAppearanceLightNavigationBars = !isDarkThemeForBottom | |
if (hideStatusBar) { | |
isProgrammaticChange = true // Set the flag before programmatic change | |
windowInsetsController.hide(WindowInsetsCompat.Type.statusBars()) | |
} else { | |
isProgrammaticChange = true // Set the flag before programmatic change | |
windowInsetsController.show(WindowInsetsCompat.Type.statusBars()) | |
} | |
if (hideNavigationBar) { | |
isProgrammaticChange = true // Set the flag before programmatic change | |
windowInsetsController.hide(WindowInsetsCompat.Type.navigationBars()) | |
} else { | |
isProgrammaticChange = true // Set the flag before programmatic change | |
windowInsetsController.show(WindowInsetsCompat.Type.navigationBars()) | |
} | |
} else { | |
window.decorView.systemUiVisibility = ( | |
View.SYSTEM_UI_FLAG_LAYOUT_STABLE | |
or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | |
or View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | |
or if (hideStatusBar) View.SYSTEM_UI_FLAG_FULLSCREEN else 0 | |
or if (hideNavigationBar) View.SYSTEM_UI_FLAG_HIDE_NAVIGATION else 0 | |
or if (!isDarkThemeForStatusBar) | |
View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR | |
else 0 | |
or if (!isDarkThemeForBottom) | |
View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR else 0) | |
window.decorView.setOnSystemUiVisibilityChangeListener { visibility -> | |
val isStatusBarVisible = visibility and View.SYSTEM_UI_FLAG_FULLSCREEN == 0 | |
val isNavigationBarVisible = visibility and View.SYSTEM_UI_FLAG_HIDE_NAVIGATION == 0 | |
if (!isProgrammaticChange) { | |
onSystemBarsVisibilityChange(isStatusBarVisible, isNavigationBarVisible) | |
} | |
isProgrammaticChange = false | |
} | |
} | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\ui\theme\Type.kt | |
```kt | |
package com.divadventure.ui.theme | |
import androidx.compose.material3.Typography | |
import androidx.compose.ui.text.TextStyle | |
import androidx.compose.ui.text.font.FontFamily | |
import androidx.compose.ui.text.font.FontWeight | |
import androidx.compose.ui.unit.sp | |
// Set of Material typography styles to start with | |
val Typography = Typography( | |
bodyLarge = TextStyle( | |
fontFamily = FontFamily.Default, | |
fontWeight = FontWeight.Normal, | |
fontSize = 16.sp, | |
lineHeight = 24.sp, | |
letterSpacing = 0.5.sp | |
) | |
/* Other default text styles to override | |
titleLarge = TextStyle( | |
fontFamily = FontFamily.Default, | |
fontWeight = FontWeight.Normal, | |
fontSize = 22.sp, | |
lineHeight = 28.sp, | |
letterSpacing = 0.sp | |
), | |
labelSmall = TextStyle( | |
fontFamily = FontFamily.Default, | |
fontWeight = FontWeight.Medium, | |
fontSize = 11.sp, | |
lineHeight = 16.sp, | |
letterSpacing = 0.5.sp | |
) | |
*/ | |
) | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\viewmodel\AccountViewModel.kt | |
```kt | |
package com.divadventure.viewmodel | |
class AccountViewModel { | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\viewmodel\auth\LoginModels.kt | |
```kt | |
package com.divadventure.viewmodel.auth | |
sealed class LoginIntent { | |
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() | |
} | |
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 | |
) | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\viewmodel\auth\LoginViewModel.kt | |
```kt | |
package com.divadventure.viewmodel.auth | |
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.NavigateTo | |
import com.divadventure.data.navigation.NavigationEvent.PopSpecific | |
import com.divadventure.data.navigation.Screen | |
import com.divadventure.di.SharedPrefs | |
import com.divadventure.domain.models.ReqLogin | |
import com.divadventure.domain.models.ResVerifyEmail | |
import com.divadventure.domain.models.SignUpResponse | |
import com.divadventure.ui.screens.UserDataManager | |
import com.divadventure.util.NetworkManager | |
import com.divadventure.viewmodel.AuthUiEvent | |
import com.divadventure.viewmodel.AuthUiEvent.ExecuteNavigation | |
import com.divadventure.viewmodel.AuthUiEvent.ShowSnackbar | |
import com.divadventure.viewmodel.BaseViewModel | |
import dagger.hilt.android.lifecycle.HiltViewModel | |
import kotlinx.coroutines.flow.MutableSharedFlow | |
import kotlinx.coroutines.flow.asSharedFlow | |
import kotlinx.coroutines.launch | |
import retrofit2.Call | |
import retrofit2.Callback | |
import retrofit2.Response | |
import javax.inject.Inject | |
@HiltViewModel | |
class LoginViewModel @Inject constructor( | |
private val sharedService: SharedService, | |
private val sharedPrefs: SharedPrefs, | |
private val networkManager: NetworkManager | |
) : BaseViewModel<LoginIntent, LoginState>(LoginState()) { | |
private val _uiEvent = MutableSharedFlow<AuthUiEvent>() | |
val uiEvent = _uiEvent.asSharedFlow() | |
override suspend fun handleIntent(intent: LoginIntent) { | |
when (intent) { | |
is LoginIntent.Login -> handleLoginIntent(intent) | |
is LoginIntent.OnForgotPasswordChanged -> handleForgotPasswordChange(intent) | |
is LoginIntent.ForgotPassword -> handleForgotPassword(intent) | |
is LoginIntent.OnEmailChanged -> handleEmailChange(intent) | |
is LoginIntent.OnPasswordChanged -> handlePasswordChange(intent) | |
} | |
} | |
private fun handleLoginIntent(intent: LoginIntent.Login) { | |
if (!state.value.loginClickable) return | |
checkLogin(intent.emailOrUsername, intent.password) | |
} | |
private fun handleForgotPasswordChange(intent: LoginIntent.OnForgotPasswordChanged) { | |
updateState( | |
state.value.copy( | |
forgetPasswordEmailCorrect = isEmailValid(intent.email), | |
email = intent.email | |
) | |
) | |
} | |
private fun handleForgotPassword(intent: LoginIntent.ForgotPassword) { | |
if (!state.value.forgetPasswordEmailCorrect) return | |
if (!networkManager.isNetworkConnected()) { | |
emitUiEvent("Internet Issue", "No Internet Connection") | |
return | |
} | |
sharedService.forgotPassword(ResVerifyEmail(email = intent.forgotEmail)) | |
.enqueue(createForgotPasswordCallback(intent.forgotEmail)) | |
} | |
private fun handleEmailChange(intent: LoginIntent.OnEmailChanged) { | |
updateState(state.value.copy(email = intent.email)) | |
validationLogin(state.value.email, state.value.password) | |
} | |
private fun handlePasswordChange(intent: LoginIntent.OnPasswordChanged) { | |
updateState(state.value.copy(password = intent.password)) | |
validationLogin(state.value.email, state.value.password) | |
} | |
private fun checkLogin(username: String, password: String) { | |
val loginState = validationLogin(username, password) | |
if (!loginState.isValid) { | |
updateState( | |
state.value.copy( | |
formError = loginState.errorMessage, | |
formDataValid = loginState.isValid, | |
loginClickable = isEmailValid(state.value.email) && state.value.password.isNotEmpty() | |
) | |
) | |
return | |
} | |
if (!networkManager.isNetworkConnected()) { | |
emitUiEvent("Internet Issue", "No Internet Connection") | |
return | |
} | |
updateState(state.value.copy(loginClickable = false)) | |
sharedService.login(ReqLogin(username, password)).enqueue(createLoginCallback()) | |
} | |
private fun validationLogin(username: String, password: String): FormValidationState { | |
return when { | |
username.isEmpty() || password.isEmpty() -> FormValidationState.ERROR_EMPTY_FIELDS | |
!isEmailValid(username) -> FormValidationState.ERROR_INVALID_EMAIL | |
else -> FormValidationState.VALID | |
} | |
} | |
private fun createForgotPasswordCallback(email: String) = object : Callback<Unit> { | |
override fun onResponse(call: Call<Unit?>, response: Response<Unit?>) { | |
if (response.isSuccessful) { | |
viewModelScope.launch { | |
navigateToForgotPasswordVerification() | |
} | |
} else { | |
emitUiEvent("An error occurred", "Forgot Password email not sent successfully") | |
} | |
} | |
override fun onFailure(call: Call<Unit?>, t: Throwable) { | |
emitUiEvent("An error occurred", "Forgot Password email not sent successfully") | |
} | |
} | |
private suspend fun navigateToForgotPasswordVerification() { | |
updateState(state.value.copy()) | |
_uiEvent.emit( | |
ExecuteNavigation( | |
NavigateTo( | |
screen = Screen.ForgotPassword, | |
popUpTo = Screen.Login, | |
inclusive = true, | |
onDestinationChangedListener = object : NavController.OnDestinationChangedListener { | |
override fun onDestinationChanged( | |
controller: NavController, | |
destination: NavDestination, | |
arguments: Bundle? | |
) { | |
controller.removeOnDestinationChangedListener(this) | |
} | |
} | |
) | |
) | |
) | |
} | |
private fun createLoginCallback() = object : Callback<SignUpResponse> { | |
override fun onResponse( | |
call: Call<SignUpResponse?>, | |
response: Response<SignUpResponse?> | |
) { | |
if (response.isSuccessful && response.body() != null) { | |
val body = response.body()!! | |
UserDataManager(sharedPrefs).savePrimaryData(body) | |
onLoginSuccess() | |
} else { | |
emitUiEvent("Login failed", "Invalid credentials") | |
} | |
} | |
override fun onFailure(call: Call<SignUpResponse?>, t: Throwable) { | |
val errorMessage = t.localizedMessage ?: "An unexpected error occurred. Please try again later." | |
emitUiEvent("Login failed", errorMessage) | |
} | |
} | |
private fun onLoginSuccess() { | |
viewModelScope.launch { | |
_uiEvent.emit(ExecuteNavigation(PopSpecific(Screen.Landing, false))) | |
} | |
viewModelScope.launch { | |
_uiEvent.emit( | |
ExecuteNavigation( | |
NavigateTo( | |
screen = Screen.Main, | |
popUpTo = Screen.Landing, | |
inclusive = true, | |
onDestinationChangedListener = object : NavController.OnDestinationChangedListener { | |
override fun onDestinationChanged( | |
controller: NavController, | |
destination: NavDestination, | |
arguments: Bundle? | |
) { | |
controller.removeOnDestinationChangedListener(this) | |
} | |
} | |
) | |
) | |
) | |
} | |
} | |
private fun emitUiEvent(title: String, message: String) { | |
viewModelScope.launch { | |
_uiEvent.emit(ShowSnackbar(title, message)) | |
} | |
} | |
private fun isEmailValid(email: String): Boolean { | |
return Patterns.EMAIL_ADDRESS.matcher(email).matches() | |
} | |
private enum class FormValidationState(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) | |
} | |
} | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\viewmodel\auth\SignupModels.kt | |
```kt | |
package com.divadventure.viewmodel.auth | |
sealed class SignupIntent { | |
data class SignUp(val email: String, val password: String, val passwordConfirmation: String) : SignupIntent() | |
data class OnEmailChanged(val email: String) : SignupIntent() | |
data class OnPasswordChanged(val password: String) : SignupIntent() | |
data class OnPasswordConfirmationChanged(val passwordConfirmation: String) : SignupIntent() | |
object SignUpWithGoogle : SignupIntent() | |
} | |
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 = "", | |
) | |
``` | |
Filename: C:\Users\SajjadY\StudioProjects\DivAdventure\app\src\main\java\com\divadventure\viewmodel\auth\SignupViewModel.kt | |
```kt | |
package com.divadventure.viewmodel.auth | |
import android.os.Bundle | |
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.Screen | |
import com.divadventure.di.SharedPrefs | |
import com.divadventure.domain.models.Message | |
import com.divadventure.domain.models.SignUpResponse | |
import com.divadventure.domain.models.SignupRequest | |
import com.divadventure.ui.screens.UserDataManager | |
import com.divadventure.util.Helper.Companion.isEmailValid | |
import com.divadventure.util.NetworkManager | |
import com.divadventure.viewmodel.AuthUiEvent | |
import com.divadventure.viewmodel.AuthUiEvent.ExecuteNavigation | |
import com.divadventure.viewmodel.AuthUiEvent.ShowSnackbar | |
import com.divadventure.viewmodel.BaseViewModel | |
import com.google.gson.Gson | |
import dagger.hilt.android.lifecycle.HiltViewModel | |
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 | |
@HiltViewModel | |
class SignupViewModel @Inject constructor( | |
private val sharedService: SharedService, | |
private val sharedPrefs: SharedPrefs, | |
private val networkManager: NetworkManager | |
) : BaseViewModel<SignupIntent, SignupState>(SignupState()) { | |
private val _uiEvent = MutableSharedFlow<AuthUiEvent>() | |
val uiEvent = _uiEvent.asSharedFlow() | |
override suspend fun handleIntent(intent: SignupIntent) { | |
when (intent) { | |
is SignupIntent.SignUp -> handleSignUp(intent) | |
is SignupIntent.SignUpWithGoogle -> Unit | |
is SignupIntent.OnEmailChanged -> updateSignupState { it.copy(email = intent.email) } | |
is SignupIntent.OnPasswordChanged -> updateSignupState { | |
it.copy(password = intent.password, startedTyping = true) | |
} | |
is SignupIntent.OnPasswordConfirmationChanged -> updateSignupState { | |
it.copy(passwordConfirmation = intent.passwordConfirmation, startedTyping = true) | |
} | |
} | |
} | |
private fun handleSignUp(intent: 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) { | |
viewModelScope.launch { processSignUpResponse(response) } | |
} else { | |
emitSnackbar("Error Sign up", extractErrorMessage(response)) | |
} | |
} | |
override fun onFailure(call: Call<SignUpResponse>, t: Throwable) { | |
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") | |
viewModelScope.launch { | |
_uiEvent.emit(ShowSnackbar(title, message)) | |
} | |
} | |
private suspend fun processSignUpResponse(response: Response<SignUpResponse>) { | |
response.body()?.let { | |
if (response.isSuccessful) { | |
saveUserData(it) | |
resetSignupState() | |
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? | |
) { | |
controller.removeOnDestinationChangedListener(this) | |
} | |
}) | |
) | |
} | |
private suspend fun updateSignupState(update: (SignupState) -> SignupState) { | |
updateState(update(state.value)) | |
validateSignupForm() | |
} | |
private fun validateSignupForm() { | |
val st = state.value | |
val formError = validateSignupFields(st.email, st.password, st.passwordConfirmation) | |
updateState( | |
st.copy( | |
formError = formError.orEmpty(), | |
formDataValid = formError == null, | |
passwordsMatch = st.password == st.passwordConfirmation, | |
is8Characters = st.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()) { | |
viewModelScope.launch { | |
showSnackbar("Internet Issue", "No Internet Connection") | |
} | |
return false | |
} | |
return state.value.formDataValid | |
} | |
private fun finalizeSignupProcess() { | |
enableSignupButton() | |
updateLoadingState(false) | |
} | |
private fun showSnackbar(title: String, message: String) { | |
viewModelScope.launch { | |
_uiEvent.emit(ShowSnackbar(title, message)) | |
} | |
} | |
private fun updateLoadingState(isLoading: Boolean) { | |
updateState(state.value.copy(isLoadingSignUp = isLoading)) | |
} | |
private fun enableSignupButton() { | |
updateState(state.value.copy(signupClickable = true)) | |
} | |
private fun saveUserData(body: SignUpResponse) { | |
UserDataManager(sharedPrefs).savePrimaryData(body) | |
} | |
private fun resetSignupState() { | |
updateState( | |
SignupState( | |
email = state.value.email | |
) | |
) | |
} | |
private fun emitNavigationEvent(navigationEvent: NavigateTo) { | |
viewModelScope.launch { | |
_uiEvent.emit(ExecuteNavigation(navigationEvent)) | |
} | |
} | |
private fun extractErrorMessage(response: Response<SignUpResponse>) = | |
response.errorBody()?.string() | |
?.let { Gson().fromJson(it, Message::class.java)?.message } ?: "Unknown Error" | |
} | |
``` | |
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\build.gradle.kts | |
```kts | |
plugins { | |
alias(libs.plugins.android.application) apply false | |
alias(libs.plugins.kotlin.android) apply false | |
alias(libs.plugins.kotlin.compose) apply false | |
id("com.google.devtools.ksp") version "2.1.0-1.0.29" apply false | |
id("com.google.dagger.hilt.android") version "2.51.1" apply false // or latest version | |
id("org.jetbrains.kotlin.plugin.serialization") version "1.9.22" // Replace with your current Kotlin version | |
} | |
``` | |
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: | |
- **BaseViewModel**: | |
- The app uses a custom `BaseViewModel` abstract class that serves as the foundation for all ViewModels in the application. | |
- It implements a standardized MVI approach for handling intents through a Kotlin `Channel`, managing state via `StateFlow`, and dealing with navigation and other one-time UI events (like snackbars or dialogs) using `SharedFlow`. This ensures events are not duplicated during configuration changes and maintains a clear separation between persistent state and transient events. | |
- 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 (e.g., `AdventuresState`, `AuthState`) that encapsulates all UI-relevant data. | |
- These states are immutable, and updates are handled through controlled state transitions (often using the `copy()` method of data classes) to ensure state integrity. | |
- ViewModels expose a single `StateFlow` for Composables to observe, simplifying the reactive data flow to the UI. | |
- **Intent Processing**: | |
- User actions from the UI are translated into strongly-typed `Intent` sealed classes/interfaces. | |
- These intents are sent to the ViewModel and processed asynchronously, typically through a Kotlin `Channel`, to avoid state conflicts and manage backpressure. | |
- Each ViewModel implements specialized intent handlers that encapsulate the business logic for specific user actions, leading to state updates or UI events. | |
--- | |
### **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. **Networking and API Communication**: | |
- **Retrofit**: Used for defining and making network API calls. | |
- **OkHttp**: Serves as the underlying HTTP client for Retrofit, handling efficient network communication for all API interactions, including those related to user authentication. | |
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 | |
│ ├── App.kt | |
│ ├── 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 | |
│ ├── MainActivity.kt | |
│ ├── ui | |
│ │ ├── AddShared.kt | |
│ │ ├── AuthShared.kt | |
│ │ ├── components | |
│ │ │ ├── AdventureActionButton.kt | |
│ │ │ ├── AdventureItem.kt | |
│ │ │ ├── AdventureTypeButton.kt | |
│ │ │ └── StaticMap.kt | |
│ │ ├── HomeShared.kt | |
│ │ ├── ManageShared.kt | |
│ │ ├── otp | |
│ │ │ ├── ModifierExt.kt | |
│ │ │ └── OtpInputField.kt | |
│ │ ├── ProfileShared.kt | |
│ │ ├── screens | |
│ │ │ ├── ChangeEmail.kt | |
│ │ │ ├── ForgotPasswordScreen.kt | |
│ │ │ ├── LandingScreen.kt | |
│ │ │ ├── Login.kt | |
│ │ │ ├── main | |
│ │ │ │ ├── add | |
│ │ │ │ │ ├── Add.kt | |
│ │ │ │ │ ├── AdventureInformation.kt | |
│ │ │ │ │ ├── AdventureOptions.kt | |
│ │ │ │ │ ├── AdventurePreview.kt | |
│ │ │ │ │ ├── manage | |
│ │ │ │ │ │ └── Manage.kt | |
│ │ │ │ │ ├── ManageAdventure.kt | |
│ │ │ │ │ └── ManageAdventureOwnerModerator.kt | |
│ │ │ │ ├── home | |
│ │ │ │ │ ├── Home.kt | |
│ │ │ │ │ ├── notifications | |
│ │ │ │ │ │ ├── Notifications.kt | |
│ │ │ │ │ │ └── search | |
│ │ │ │ │ │ ├── filter | |
│ │ │ │ │ │ │ ├── Filter.kt | |
│ │ │ │ │ │ │ ├── Interests.kt | |
│ │ │ │ │ │ │ ├── Location.kt | |
│ │ │ │ │ │ │ └── Status.kt | |
│ │ │ │ │ │ ├── Search.kt | |
│ │ │ │ │ │ └── sortby | |
│ │ │ │ │ │ └── SortBy.kt | |
│ │ │ │ │ └── profile | |
│ │ │ │ ├── Main.kt | |
│ │ │ │ └── profile | |
│ │ │ │ ├── AccountSettings.kt | |
│ │ │ │ ├── NotificationsSettings.kt | |
│ │ │ │ ├── PrivacySettings.kt | |
│ │ │ │ └── Profile.kt | |
│ │ │ ├── Onboarding.kt | |
│ │ │ ├── ResetPassword.kt | |
│ │ │ ├── SignUp.kt | |
│ │ │ ├── SplashScreen.kt | |
│ │ │ ├── UserDataManager.kt | |
│ │ │ ├── Verification.kt | |
│ │ │ └── VerificationResendEmail.kt | |
│ │ └── theme | |
│ │ ├── Color.kt | |
│ │ ├── Theme.kt | |
│ │ └── Type.kt | |
│ └── viewmodel | |
│ ├── AccountViewModel.kt | |
│ ├── auth | |
│ │ ├── LoginModels.kt | |
│ │ ├── LoginViewModel.kt | |
│ │ ├── SignupModels.kt | |
│ │ └── SignupViewModel.kt | |
│ ├── AuthViewModel.kt | |
│ ├── BaseViewModel.kt | |
│ ├── HomeViewModel.kt | |
│ ├── MainViewModel.kt | |
│ ├── ManageAdventureViewModel.kt | |
│ ├── NotificationsViewModel.kt | |
│ └── ProfileViewModel.kt | |
├── build.gradle.kts | |
└── readme.md |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment