package com.suno.android.ui.screens.home import androidx.core.net.toUri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import arrow.core.getOrElse import com.suno.android.common_analytics.ActionName import com.suno.android.common_analytics.managers.AnalyticsManager import com.suno.android.common_core_utils.Id import com.suno.android.common_core_utils.SunoLogger import com.suno.android.common_core_utils.constants.ReactionType import com.suno.android.common_core_utils.environment.ThemeMode import com.suno.android.common_core_utils.environment.UserPrefsDataStoreManager import com.suno.android.common_core_utils.helpers.PeriodicFlowExecutor import com.suno.android.common_core_utils.model.Url import com.suno.android.common_core_utils.model.UserHandle import com.suno.android.common_data.alerts.AlertsRepo import com.suno.android.common_data.alerts.InAppNotificationsState import com.suno.android.common_data.billing.SunoBillingRepo import com.suno.android.common_data.comments.Comment import com.suno.android.common_data.mappers.clips.SongListData import com.suno.android.common_data.mappers.clips.xAsLocalClipData import com.suno.android.common_data.mappers.hooks.LocalHookData import com.suno.android.common_data.promos.PromoBannersRepository import com.suno.android.common_data.repos.ClipsRepository import com.suno.android.common_data.user.UserSessionRepository import com.suno.android.common_networking.extensions.toThrowable import com.suno.android.common_ui.models.Promo import com.suno.android.deeplink.DeferredDeepLinkManager import com.suno.android.deeplink.DeferredDeeplink import com.suno.android.extensions.xAsPromo import com.suno.android.gating.Feature import com.suno.android.gating.FeatureManager import com.suno.android.hooks.HooksFeatureGateManager import com.suno.android.hooks.HooksFeedTab import com.suno.android.media.MediaManager import com.suno.android.ui.screens.home.tabs.BottomNavTabItem import com.suno.android.ui.screens.home.tabs.BottomTabBarManager import com.suno.android.ui.screens.home.tabs.DynamicBottomNavConfiguration import com.suno.android.ui.screens.navigation.NavDestination import com.suno.android.ui.screens.navigation.NavDestination.Home.HooksFeedScreenDestination import com.suno.android.utils.resolvers.ScreenVisitResolver import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList 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.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class HomeScreenNavGraphVM @Inject constructor( loggerFactory: SunoLogger.Factory, private val mediaManager: MediaManager, private val clipsRepository: ClipsRepository, private val deferredDeepLinkManager: DeferredDeepLinkManager, private val userPrefsDataStoreManager: UserPrefsDataStoreManager, private val alertsRepo: AlertsRepo, private val analyticsManager: AnalyticsManager, private val bottomTabBarManager: BottomTabBarManager, private val screenVisitResolver: ScreenVisitResolver, private val userSessionRepository: UserSessionRepository, private val promoBannersRepository: PromoBannersRepository, featureManager: FeatureManager, billingManager: SunoBillingRepo, hooksFeatureGateManager: HooksFeatureGateManager, ) : ViewModel() { data class HookRedirectData( val hookId: Id, val commentId: Id? = null, ) private val logger = loggerFactory.create(this@HomeScreenNavGraphVM) private val refreshNotificationsPollingFlowExecutor = PeriodicFlowExecutor( intervalMs = 30000, scope = viewModelScope, flowProvider = { flow { emit(alertsRepo.syncNotifications()) } }, ) private val _effects = MutableSharedFlow() val effects: SharedFlow get() = _effects.asSharedFlow() private val _redirectToCreateStateFlow = MutableStateFlow(null) private val bottomNavTabRouteMapping = BottomNavTabItem.entries.associate { it.destination::class.qualifiedName to it.destination } fun redirectToCreateStateFlow() = _redirectToCreateStateFlow.asStateFlow() fun resetRedirectToCreateStateFlow() { _redirectToCreateStateFlow.update { null } } private val _redirectToBillingStateFlow = MutableStateFlow(null) fun redirectToBillingStateFlow() = _redirectToBillingStateFlow.asStateFlow() fun resetRedirectToBillingStateFlow() { _redirectToBillingStateFlow.update { null } } private val _redirectToProfileStateFlow = MutableStateFlow(null) fun redirectToProfileStateFlow() = _redirectToProfileStateFlow.asStateFlow() fun resetRedirectToProfileStateFlow() { _redirectToProfileStateFlow.update { null } } private val _redirectToHooksFeedStateFlow = MutableStateFlow(null) fun redirectToHooksFeedStateFlow() = _redirectToHooksFeedStateFlow.asStateFlow() fun resetRedirectToHooksFeedStateFlow() { _redirectToHooksFeedStateFlow.update { null } } private val _redirectToHookStateFlow = MutableStateFlow(null) fun redirectToHookStateFlow() = _redirectToHookStateFlow.asStateFlow() fun resetRedirectToHookStateFlow() { _redirectToHookStateFlow.update { null } } private val _redirectToPlayerStateFlow = MutableStateFlow(null) fun redirectToPlayerStateFlow() = _redirectToPlayerStateFlow.asStateFlow() fun resetRedirectToPlayerStateFlow() { _redirectToPlayerStateFlow.update { null } } val dynamicBottomNavConfiguration = when (hooksFeatureGateManager.hooksFeedTab) { HooksFeedTab.First -> DynamicBottomNavConfiguration( firstTab = BottomNavTabItem.HooksFeed, secondTab = BottomNavTabItem.Explore, fourthTab = BottomNavTabItem.Library, ) HooksFeedTab.Second -> DynamicBottomNavConfiguration( firstTab = BottomNavTabItem.Home, secondTab = BottomNavTabItem.HooksFeed, fourthTab = BottomNavTabItem.Library, ) HooksFeedTab.Disabled -> DynamicBottomNavConfiguration( firstTab = BottomNavTabItem.Home, secondTab = BottomNavTabItem.Library, fourthTab = BottomNavTabItem.Notifications, ) } private val _viewStateFlow = MutableStateFlow( UiState( selectedTabDestination = dynamicBottomNavConfiguration.firstTab.destination, isV5DialogGateEnabled = featureManager.hasFeature(Feature.ShowV5PromoDialog), isPromoDialogGateEnabled = featureManager.hasFeature(Feature.ShowPromoDialog), ), ) fun viewStateFlow(): StateFlow = _viewStateFlow.asStateFlow() fun onNavDestinationChanged( screenName: String?, ) { val newDestination = bottomNavTabRouteMapping[screenName] ?: return _viewStateFlow.update { it.copy(selectedTabDestination = newDestination) } } fun trackScreen( screenName: String?, ) { screenVisitResolver.resolve(screenName)?.value?.let { analyticsManager.trackScreenVisit(it) } } init { refreshNotificationsPollingFlowExecutor.flow().launchIn(viewModelScope) deferredDeepLinkManager.deferredDeeplinkFlow() .onEach { deferredDeeplink: DeferredDeeplink -> when (deferredDeeplink) { is DeferredDeeplink.Create -> { _redirectToCreateStateFlow.update { true } } is DeferredDeeplink.Subscribe -> { _redirectToBillingStateFlow.update { true } } is DeferredDeeplink.Hooks -> { _redirectToHooksFeedStateFlow.update { true } } is DeferredDeeplink.Notifications -> { _viewStateFlow.update { oldState -> oldState.copy( selectedTabDestination = NavDestination.Home.NotificationScreenDestination, ) } } is DeferredDeeplink.OnClipIdDeeplink -> { setSelectedSongById( clipId = deferredDeeplink.clipId, ) _redirectToPlayerStateFlow.update { true } } is DeferredDeeplink.OnPlaylistIdDeeplink -> { if (deferredDeeplink.fromInternalSource) { _effects.emit( HomeNavScreenEffects.RedirectToPlaylist( playlistId = deferredDeeplink.playlistId, styleTag = deferredDeeplink.styleTag, ), ) } else { setSelectedPlaylistById( playlistId = deferredDeeplink.playlistId, ) } } is DeferredDeeplink.OnHookIdDeeplink -> { _redirectToHookStateFlow.update { HookRedirectData( hookId = deferredDeeplink.hookId, commentId = deferredDeeplink.commentId, ) } } is DeferredDeeplink.OnProfileIdDeeplink -> { _redirectToProfileStateFlow.update { deferredDeeplink.userHandle } } else -> { // no-op } } deferredDeepLinkManager.reset() }.catch { exception -> logger.e(exception) }.launchIn(viewModelScope) userSessionRepository.sessionConfigurationStateFlow() .onEach { sessionConfiguration -> val user = sessionConfiguration.user _viewStateFlow.update { it.copy( myUserAvatarUrl = user?.avatarImageUrl?.let(::Url), myUserHandle = user?.handle, ) } }.catch { exception -> logger.e(exception) }.launchIn(viewModelScope) combine( userPrefsDataStoreManager.getHasSeenV5Dialog(), billingManager.billingStateFlow(), ) { hasSeenV5Dialog, billing -> Pair(hasSeenV5Dialog, billing) }.onEach { (hasSeenV5Dialog, subscriptionInfo) -> val isPro = subscriptionInfo?.plan != null if (!hasSeenV5Dialog) { _viewStateFlow.update { it.copy( showingV5Dialog = true, promoDialogType = if (isPro) PromoDialogType.ProUser else PromoDialogType.FreeUser, ) } } }.catch { exception -> logger.e(exception) }.launchIn(viewModelScope) if (_viewStateFlow.value.isPromoDialogGateEnabled) { viewModelScope.launch { promoBannersRepository.getPromoBanners().getOrNull()?.let { banners -> if (banners.isNotEmpty()) { _viewStateFlow.update { val promos = banners .mapNotNull { banner -> banner.xAsPromo( onCtaClick = { banner.ctaUrl?.let { onPromoDialogCtaClicked(it) } }, ).getOrNull() } .toImmutableList() it.copy( showingPromoDialog = true, promos = promos, ) } } } } } alertsRepo.inAppNotificationsStateFlow() .onEach { state: InAppNotificationsState -> _viewStateFlow.update { oldState -> oldState.copy( hasUnreadNotifications = state.hasUnreadNotifications, ) } }.catch { logger.e(it) }.launchIn(viewModelScope) updateThemeMode() updateDefaultTab() } private fun updateThemeMode() { userPrefsDataStoreManager.getThemeMode() .onEach { themeMode -> _viewStateFlow.update { oldState -> oldState.copy( themeMode = themeMode, ) } } .catch { exception -> logger.e(exception) } .launchIn(viewModelScope) } private fun updateDefaultTab() { val isHooksFeedFirstTab = dynamicBottomNavConfiguration.firstTab.destination == HooksFeedScreenDestination val isDayZeroUser = userSessionRepository.sessionConfigurationStateFlow().value.roles?.isDayZeroUser == true logger.d { "Hooks feed first tab: $isHooksFeedFirstTab, day zero user: $isDayZeroUser" } // When day zero user first logs in and hooks feed is first tab, open the app to the second tab if (isHooksFeedFirstTab && isDayZeroUser) { viewModelScope.launch { val isFirstLaunch = userPrefsDataStoreManager.getHooksFeedFirstLaunch().firstOrNull().also { logger.d { "First launch: $it" } } if (isFirstLaunch == true) { _viewStateFlow.update { oldState -> oldState.copy( selectedTabDestination = dynamicBottomNavConfiguration.secondTab.destination, ) } userPrefsDataStoreManager.setHooksFeedFirstLaunch() } } } } fun deepLinkToProfileByHandle( handle: UserHandle, ) { deferredDeepLinkManager.setDeferredDeepLink( DeferredDeeplink.OnProfileIdDeeplink( userHandle = handle, ), ) } fun clearReadNotificationIndicator() { _viewStateFlow.update { oldState -> oldState.copy( hasUnreadNotifications = false, ) } } fun onV5DialogDismissed() { viewModelScope.launch { userPrefsDataStoreManager.setHasSeenV5Dialog(hasSeen = true) _viewStateFlow.update { it.copy(showingV5Dialog = false) } } } fun onPromoDialogDismissed() { _viewStateFlow.update { it.copy(showingPromoDialog = false) } } fun onPromoDialogCtaClicked( url: String, ) { _viewStateFlow.update { it.copy(showingPromoDialog = false) } viewModelScope.launch { deferredDeepLinkManager.processRawDeeplinkUri(url.toUri(), fromInternalSource = true) } } fun onPromoBannerSeen( promoId: Id, ) { viewModelScope.launch { promoBannersRepository.markBannerAsSeen(promoId.value) } } fun onCreateWithModelClicked( model: String, ) { viewModelScope.launch { userPrefsDataStoreManager.setSelectedModel( selectedModel = model, ) _effects.emit(HomeNavScreenEffects.RedirectToCreate) } } fun onBottomBarDestinationClick( navDestination: NavDestination, ) { val bottomBarDestination = when (navDestination) { is NavDestination.Create -> navDestination is NavDestination.Home -> { _viewStateFlow.update { oldState -> if (oldState.selectedTabDestination != navDestination) { oldState.copy(selectedTabDestination = navDestination) } else { viewModelScope.launch { _effects.emit(HomeNavScreenEffects.ReloadDestination(navDestination)) } oldState } } navDestination } else -> { logger.d { "NavDestination is not Home or Create bottom tab: $navDestination" } return } } bottomTabBarManager.setCurrentTab(bottomBarDestination) trackTabTap(bottomBarDestination) } private suspend fun setSelectedSongById( clipId: Id, ) { val clip = clipsRepository.getClipById(clipId) .getOrElse { error -> logger.e(error) { "Error loading clip for song selection" } deferredDeepLinkManager.reset() return } clip?.let(mediaManager::setSinglePlayingClipData) deferredDeepLinkManager.reset() } private suspend fun setSelectedPlaylistById( playlistId: String, ) { val playlist = mediaManager.loadFullPlaylistById(playlistId).getOrElse { error -> logger.e(error.toThrowable()) return } val localClipData = playlist.playlistClips.map { val reaction = when (it.clip.clipReaction?.reactionType) { "L" -> ReactionType.LIKE "D" -> ReactionType.DISLIKE else -> null } it.clip.xAsLocalClipData().copy(reaction = reaction) } mediaManager.setCurrentlyPlayingPlaylist( songList = localClipData, chosenSong = localClipData.first(), ) deferredDeepLinkManager.reset() } fun handleRedirectUrl( url: String, styleTag: String? = null, ) { viewModelScope.launch { deferredDeepLinkManager.processRawDeeplinkPath( uri = url.toUri(), path = url, fromInternalSource = true, styleTag = styleTag, ) } } private fun trackTabTap( navDestination: NavDestination, ) { analyticsManager.trackMviEvent( eventName = ActionName.TabTapped.value, source = HomeScreenNavGraphAnalytics.SCREEN, context = HomeScreenNavGraphAnalytics.createMetadata(navDestination), ) } data class UiState( val myUserAvatarUrl: Url? = null, val myUserHandle: String? = null, val loading: Boolean = false, val selectedTabDestination: NavDestination.Home, val promoDialogType: PromoDialogType = PromoDialogType.FreeUser, val showingV5Dialog: Boolean = false, val isV5DialogGateEnabled: Boolean = false, val showingPromoDialog: Boolean = false, val promos: ImmutableList = persistentListOf(), val isPromoDialogGateEnabled: Boolean = false, val hasUnreadNotifications: Boolean = false, val themeMode: ThemeMode = ThemeMode.SYSTEM, ) enum class PromoDialogType { ProUser, FreeUser, } sealed interface HomeNavScreenEffects { data object RedirectToCreate : HomeNavScreenEffects data object RedirectToBilling : HomeNavScreenEffects data class RedirectToPlaylist( val playlistId: String, val styleTag: String? = null, ) : HomeNavScreenEffects data class ReloadDestination( val destination: NavDestination.Home, ) : HomeNavScreenEffects } }