package com.suno.android.ui.bottom_sheets.projects import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateFloatAsState 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.Row import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.asPaddingValues 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.heightIn import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.DropdownMenu import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.SheetState import androidx.compose.material3.Text import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.rememberModalBottomSheetState 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.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.suno.android.R import com.suno.android.common_core_utils.Id import com.suno.android.common_data.mappers.projects.ProjectData import com.suno.android.common_data.mappers.projects.ProjectMetadata import com.suno.android.common_ui.components.bottom_sheet.projects.CreateProjectBottomSheet import com.suno.android.common_ui.components.loading.SunoSpinner import com.suno.android.common_ui.effects.OnEndOfListReachedEffect import com.suno.android.common_ui.theme.ExtendedTheme import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.launch import com.suno.android.common_res.R as CommonResR import com.suno.android.common_ui.R as CommonUIR @Composable fun SelectProjectBottomSheet( projects: ImmutableList, onProjectSelected: (ProjectMetadata) -> Unit, selectedProjectId: Id? = null, onCreateProject: (projectName: String) -> Unit, onLoadMoreProjects: () -> Unit, areProjectsLoading: Boolean, isMoreProjectsLoading: Boolean = false, onDismiss: () -> Unit, sheetState: SheetState = rememberModalBottomSheetState(false), ) { val windowInsetsNone = remember { WindowInsets(0.dp) } val scope = rememberCoroutineScope() var showCreateProjectBottomSheet by remember { mutableStateOf(false) } val createProjectBottomSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) ModalBottomSheet( onDismissRequest = { onDismiss() }, sheetState = sheetState, contentWindowInsets = { windowInsetsNone }, modifier = Modifier.windowInsetsPadding(WindowInsets.statusBars), ) { Row( modifier = Modifier .fillMaxWidth() .padding(top = 12.dp, bottom = 16.dp), horizontalArrangement = Arrangement.Center, ) { Text( text = "Workspaces", style = ExtendedTheme.typography.introCopy, ) } WorkspaceContent( projects = projects, selectedProjectId = selectedProjectId, areProjectsLoading = areProjectsLoading, isMoreProjectsLoading = isMoreProjectsLoading, onProjectSelected = { onProjectSelected(it) onDismiss() }, onCreateProjectClick = { showCreateProjectBottomSheet = true scope.launch { createProjectBottomSheetState.show() } }, loadMoreProjects = onLoadMoreProjects, modifier = Modifier.weight(1f), ) } if (showCreateProjectBottomSheet) { CreateProjectBottomSheet( onDismiss = { showCreateProjectBottomSheet = false scope.launch { createProjectBottomSheetState.hide() } }, onConfirm = { newProjectName -> onCreateProject(newProjectName) scope.launch { createProjectBottomSheetState.hide() showCreateProjectBottomSheet = false } }, ) } } @Composable fun WorkspaceContent( projects: ImmutableList, selectedProjectId: Id?, areProjectsLoading: Boolean, isMoreProjectsLoading: Boolean, onProjectSelected: (ProjectMetadata) -> Unit, onCreateProjectClick: () -> Unit, loadMoreProjects: () -> Unit, modifier: Modifier = Modifier, itemMoreActionsContent: @Composable ((ProjectMetadata, DismissActionMenuLambda) -> Unit)? = null, ) { var searchQuery by remember { mutableStateOf("") } Column( modifier = modifier, ) { Row( modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 12.dp), ) { TextField( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(60.dp)) .background(ExtendedTheme.colors.backgroundTertiary), value = searchQuery, onValueChange = { searchQuery = it }, placeholder = { Text( style = ExtendedTheme.typography.body, color = ExtendedTheme.colors.textTertiary, text = stringResource(CommonResR.string.search), ) }, textStyle = ExtendedTheme.typography.body, singleLine = true, colors = TextFieldDefaults.colors( focusedContainerColor = Color.Transparent, unfocusedContainerColor = Color.Transparent, disabledContainerColor = Color.Transparent, focusedIndicatorColor = Color.Transparent, unfocusedIndicatorColor = Color.Transparent, ), ) } WorkspaceList( projects = projects, onProjectSelected = onProjectSelected, areProjectsLoading = areProjectsLoading, isMoreProjectsLoading = isMoreProjectsLoading, onNewWorkspaceClick = onCreateProjectClick, loadMoreProjects = loadMoreProjects, searchQuery = searchQuery, modifier = Modifier.weight(1f), selectedProjectId = selectedProjectId, itemMoreActionsContent = itemMoreActionsContent, ) } } @Composable private fun WorkspaceList( projects: ImmutableList, selectedProjectId: Id?, areProjectsLoading: Boolean, isMoreProjectsLoading: Boolean, searchQuery: String, onProjectSelected: (ProjectMetadata) -> Unit, onNewWorkspaceClick: () -> Unit, loadMoreProjects: () -> Unit, modifier: Modifier = Modifier, itemMoreActionsContent: @Composable ((ProjectMetadata, DismissActionMenuLambda) -> Unit)? = null, ) { val lazyListState = rememberLazyListState() OnEndOfListReachedEffect(lazyListState, loadMoreProjects) Box( modifier = modifier, ) { AnimatedVisibility( visible = areProjectsLoading, modifier = Modifier.align(Alignment.Center), ) { SunoSpinner() } // animate alpha instead of using composable animations to preserve height of list during load val listAlpha by animateFloatAsState( targetValue = if (areProjectsLoading) { 0f } else { 1f }, ) LazyColumn( state = lazyListState, contentPadding = WindowInsets.navigationBars.only(WindowInsetsSides.Bottom).asPaddingValues(), modifier = Modifier .align(Alignment.TopCenter) .fillMaxWidth() .alpha(listAlpha), ) { item { NewWorkspaceItem( onClick = onNewWorkspaceClick, ) } val filteredProjects = projects.filter { project -> project.name.contains(searchQuery, ignoreCase = true) } items( items = filteredProjects, key = { it.id.value }, ) { project -> CheckableProjectListItem( project = project, isSelected = selectedProjectId == project.id, onClick = { onProjectSelected(project) }, modifier = Modifier.animateItem(), moreActionsContent = itemMoreActionsContent?.let { { dismissActionMenu -> itemMoreActionsContent(project, dismissActionMenu) } }, ) } item { AnimatedVisibility( visible = isMoreProjectsLoading, modifier = Modifier.fillMaxWidth(), ) { val loadingText = stringResource(CommonResR.string.loading) Row( modifier = Modifier .fillMaxWidth() .height(80.dp) .semantics { contentDescription = loadingText }, verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.Center, ) { SunoSpinner(modifier = Modifier.size(32.dp)) } } } } } } @Composable private fun NewWorkspaceItem( onClick: () -> Unit, modifier: Modifier = Modifier, ) { Box( modifier = modifier .fillMaxWidth() .heightIn(76.dp) .clickable( onClick = onClick, ), ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier .fillMaxSize() .padding(horizontal = 16.dp, vertical = 8.dp), ) { Icon( imageVector = ImageVector.vectorResource(id = CommonUIR.drawable.plus), contentDescription = null, modifier = Modifier .size(60.dp) .clip(RoundedCornerShape(4.dp)) .background(ExtendedTheme.colors.backgroundQuarternary) .padding(18.dp) .size(24.dp), ) Text( modifier = Modifier .weight(1f) .padding(bottom = 4.dp, start = 8.dp, end = 8.dp), text = stringResource(CommonResR.string.new_chat), style = ExtendedTheme.typography.smallBody, ) } } } private typealias DismissActionMenuLambda = () -> Unit @Composable private fun CheckableProjectListItem( project: ProjectMetadata, isSelected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, moreActionsContent: (@Composable (dismissActions: DismissActionMenuLambda) -> Unit)? = null, ) { var isMoreMenuShowing by remember { mutableStateOf(false) } Row( modifier = modifier .fillMaxWidth() .clickable(onClick = onClick) .padding(horizontal = 16.dp, vertical = 8.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { val tint = remember(project.id) { when (project.id.hashCode() % 3) { 0 -> Color.Red 1 -> Color.Blue else -> Color.Green } } Image( modifier = Modifier .size(60.dp) .clip(RoundedCornerShape(4.dp)) .background(ExtendedTheme.colors.aukPink), colorFilter = ColorFilter.tint(color = tint, blendMode = BlendMode.Overlay), painter = painterResource(id = R.drawable.ic_launcher_background), contentDescription = null, contentScale = ContentScale.Crop, ) Column( modifier = Modifier .weight(1f) .fillMaxHeight() .padding(bottom = 4.dp, start = 8.dp, end = 8.dp), verticalArrangement = Arrangement.Center, ) { Text( text = project.name.ifBlank { stringResource(CommonResR.string.untitled) }, style = ExtendedTheme.typography.smallBody, maxLines = 1, overflow = TextOverflow.Ellipsis, ) Text( text = pluralStringResource(CommonResR.plurals.songsVARIABLE, project.clipCount, project.clipCount), style = ExtendedTheme.typography.smallBody, color = ExtendedTheme.colors.textTertiary, ) } if (isSelected) { Icon( modifier = Modifier.size(32.dp), imageVector = ImageVector.vectorResource( id = CommonUIR.drawable.circle_check, ), tint = ExtendedTheme.colors.aukPink, contentDescription = stringResource(CommonResR.string.selected), ) } if (moreActionsContent != null) { Box { IconButton( onClick = { isMoreMenuShowing = true }, ) { Icon( painter = painterResource(CommonUIR.drawable.ellipsis_vertical), contentDescription = stringResource(CommonResR.string.more_options), ) } DropdownMenu( expanded = isMoreMenuShowing, onDismissRequest = { isMoreMenuShowing = false }, ) { moreActionsContent( { isMoreMenuShowing = false }, ) } } } } }