diff --git a/asg-service/src/commonMain/kotlin/io/ashdavies/aggregator/AsgService.kt b/asg-service/src/commonMain/kotlin/io/ashdavies/aggregator/AsgService.kt index 8b19d9c25..c9d013a83 100644 --- a/asg-service/src/commonMain/kotlin/io/ashdavies/aggregator/AsgService.kt +++ b/asg-service/src/commonMain/kotlin/io/ashdavies/aggregator/AsgService.kt @@ -23,3 +23,7 @@ public fun AsgService(httpClient: HttpClient): AsgService = object : AsgService public fun UpcomingConferencesCallable(httpClient: HttpClient): UpcomingConferencesCallable { return UpcomingConferencesCallable(httpClient, ASG_BASE_URL) } + +public fun PastConferencesCallable(httpClient: HttpClient): PastConferencesCallable { + return PastConferencesCallable(httpClient, ASG_BASE_URL) +} diff --git a/asg-service/src/commonMain/kotlin/io/ashdavies/aggregator/callable/PastConferencesCallable.kt b/asg-service/src/commonMain/kotlin/io/ashdavies/aggregator/callable/PastConferencesCallable.kt index a8553c924..d74aedb3d 100644 --- a/asg-service/src/commonMain/kotlin/io/ashdavies/aggregator/callable/PastConferencesCallable.kt +++ b/asg-service/src/commonMain/kotlin/io/ashdavies/aggregator/callable/PastConferencesCallable.kt @@ -6,7 +6,7 @@ import io.ktor.client.HttpClient import io.ktor.client.call.body import io.ktor.client.request.get -internal fun interface PastConferencesCallable : UnaryCallable> +public fun interface PastConferencesCallable : UnaryCallable> internal fun PastConferencesCallable( httpClient: HttpClient, diff --git a/conferences-app/src/commonMain/kotlin/io/ashdavies/party/config/CircuitConfig.kt b/conferences-app/src/commonMain/kotlin/io/ashdavies/party/config/CircuitConfig.kt index 61a4a64ae..2ca10eee7 100644 --- a/conferences-app/src/commonMain/kotlin/io/ashdavies/party/config/CircuitConfig.kt +++ b/conferences-app/src/commonMain/kotlin/io/ashdavies/party/config/CircuitConfig.kt @@ -11,13 +11,17 @@ import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.presenter.Presenter import com.slack.circuit.runtime.presenter.presenterOf import com.slack.circuit.runtime.screen.Screen +import io.ashdavies.aggregator.PastConferencesCallable import io.ashdavies.content.PlatformContext import io.ashdavies.content.reportFullyDrawn import io.ashdavies.http.DefaultHttpConfiguration +import io.ashdavies.http.LocalHttpClient import io.ashdavies.identity.IdentityManager import io.ashdavies.party.coroutines.rememberRetainedCoroutineScope import io.ashdavies.party.events.paging.rememberEventPager import io.ashdavies.party.gallery.File +import io.ashdavies.party.gallery.GalleryPresenter +import io.ashdavies.party.gallery.GalleryScreen import io.ashdavies.party.gallery.ImageManager import io.ashdavies.party.gallery.PathProvider import io.ashdavies.party.gallery.StorageManager @@ -26,9 +30,8 @@ import io.ashdavies.party.gallery.inMemoryHttpClientEngine import io.ashdavies.party.gallery.readChannel import io.ashdavies.party.home.HomePresenter import io.ashdavies.party.home.HomeScreen -import io.ashdavies.party.past.GalleryScreen -import io.ashdavies.party.past.PastEventListScreen import io.ashdavies.party.past.PastEventsPresenter +import io.ashdavies.party.past.PastEventsScreen import io.ashdavies.party.upcoming.UpcomingEventsPresenter import io.ashdavies.party.upcoming.UpcomingEventsScreen import io.ashdavies.playground.PlaygroundDatabase @@ -69,10 +72,21 @@ public fun rememberCircuit( ) .addCircuit( presenterFactory = { _, _, _ -> - presenterOf { PastEventsPresenter(imageManager, syncManager) } + presenterOf { GalleryPresenter(imageManager, syncManager) } }, uiFactory = { state, modifier -> - PastEventListScreen(state, storageManager, modifier) + GalleryScreen(state, storageManager, modifier) + }, + ) + .addCircuit( + presenterFactory = { _, _, _ -> + presenterOf { + val callable = PastConferencesCallable(LocalHttpClient.current) + PastEventsPresenter(callable) + } + }, + uiFactory = { state, modifier -> + PastEventsScreen(state, modifier) }, ) .build() diff --git a/conferences-app/src/commonMain/kotlin/io/ashdavies/party/config/RemoteConfig.kt b/conferences-app/src/commonMain/kotlin/io/ashdavies/party/config/RemoteConfig.kt index a175b0ff2..ecc48445b 100644 --- a/conferences-app/src/commonMain/kotlin/io/ashdavies/party/config/RemoteConfig.kt +++ b/conferences-app/src/commonMain/kotlin/io/ashdavies/party/config/RemoteConfig.kt @@ -7,11 +7,7 @@ import io.ashdavies.config.LocalRemoteConfig import io.ashdavies.config.RemoteConfig import io.ashdavies.config.getBoolean -internal suspend fun RemoteConfig.isProfileEnabled() = getBoolean("profile_enabled") - -internal suspend fun RemoteConfig.galleryCapture() = getBoolean("gallery_capture") - -internal suspend fun RemoteConfig.showPastEvents() = getBoolean("past_events") +internal suspend fun RemoteConfig.isGalleryEnabled() = getBoolean("gallery_enabled") @Composable internal fun booleanConfigAsState( diff --git a/conferences-app/src/commonMain/kotlin/io/ashdavies/party/gallery/GalleryPresenter.kt b/conferences-app/src/commonMain/kotlin/io/ashdavies/party/gallery/GalleryPresenter.kt new file mode 100644 index 000000000..8182bb025 --- /dev/null +++ b/conferences-app/src/commonMain/kotlin/io/ashdavies/party/gallery/GalleryPresenter.kt @@ -0,0 +1,82 @@ +package io.ashdavies.party.gallery + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import com.slack.circuit.retained.produceRetainedState +import com.slack.circuit.retained.rememberRetained +import kotlinx.coroutines.launch + +@Composable +internal fun GalleryPresenter( + imageManager: ImageManager, + syncManager: SyncManager, +): GalleryScreen.State { + val itemList by produceRetainedState(emptyList()) { + imageManager.list.collect { value = it } + } + + val syncState by produceRetainedState(emptyMap()) { + syncManager.state.collect { value = it } + } + + val coroutineScope = rememberCoroutineScope() + + var expandedItem by rememberRetained { + mutableStateOf(null) + } + + var selected by rememberRetained { mutableStateOf(emptyList()) } + var takePhoto by rememberRetained { mutableStateOf(false) } + + return GalleryScreen.State( + itemList = itemList.map { + GalleryScreen.State.StandardItem( + title = it.name, + imageModel = File(it.path), + isSelected = it in selected, + state = syncState[it.name] ?: SyncState.NOT_SYNCED, + ) + }, + expandedItem = expandedItem, + showCapture = takePhoto, + ) { event -> + when (event) { + is GalleryScreen.Event.Capture.Result -> coroutineScope.launch { + imageManager.add(event.value) + takePhoto = false + } + + is GalleryScreen.Event.Capture.Cancel -> takePhoto = false + is GalleryScreen.Event.Capture.Request -> takePhoto = true + + is GalleryScreen.Event.Selection.Expand -> { + expandedItem = GalleryScreen.State.ExpandedItem( + contentDescription = itemList[event.index].name, + imageModel = File(itemList[event.index].path), + isExpanded = true, + ) + } + + is GalleryScreen.Event.Selection.Toggle -> itemList[event.index].also { + if (it in selected) selected -= it else selected += it + } + + is GalleryScreen.Event.Selection.Collapse -> { + expandedItem = expandedItem?.copy(isExpanded = false) + } + + is GalleryScreen.Event.Selection.Delete -> coroutineScope.launch { + selected.forEach { imageManager.remove(it) } + selected = emptyList() + } + + is GalleryScreen.Event.Selection.Sync -> coroutineScope.launch { + selected.forEach { syncManager.sync(it.path) } + selected = emptyList() + } + } + } +} diff --git a/conferences-app/src/commonMain/kotlin/io/ashdavies/party/gallery/GalleryScreen.kt b/conferences-app/src/commonMain/kotlin/io/ashdavies/party/gallery/GalleryScreen.kt new file mode 100644 index 000000000..b3299fad8 --- /dev/null +++ b/conferences-app/src/commonMain/kotlin/io/ashdavies/party/gallery/GalleryScreen.kt @@ -0,0 +1,373 @@ +package io.ashdavies.party.gallery + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.expandIn +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkOut +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement.spacedBy +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.aspectRatio +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.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults.enterAlwaysScrollBehavior +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +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.drawscope.Stroke +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import coil3.compose.rememberAsyncImagePainter +import com.slack.circuit.foundation.internal.BackHandler +import com.slack.circuit.runtime.CircuitUiEvent +import com.slack.circuit.runtime.CircuitUiState +import com.slack.circuit.runtime.screen.Screen +import io.ashdavies.analytics.OnClick +import io.ashdavies.parcelable.Parcelable +import io.ashdavies.parcelable.Parcelize +import io.ashdavies.party.events.EventsTopBar +import io.ashdavies.party.material.BottomSheetScaffold +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import org.jetbrains.compose.resources.stringResource +import playground.conferences_app.generated.resources.Res +import playground.conferences_app.generated.resources.past_events + +private const val DEFAULT_COLUMN_COUNT = 4 + +@Parcelize +internal object GalleryScreen : Parcelable, Screen { + sealed interface Event : CircuitUiEvent { + sealed interface Capture : Event { + data class Result(val value: File) : Capture + + data object Cancel : Capture + data object Request : Capture + } + + sealed interface Selection : Event { + data class Expand(val index: Int) : Selection + data class Toggle(val index: Int) : Selection + + data object Collapse : Selection + data object Delete : Selection + data object Sync : Selection + } + } + + internal data class State( + val itemList: List = emptyList(), + val expandedItem: ExpandedItem? = null, + val showCapture: Boolean = false, + val eventSink: (Event) -> Unit, + ) : CircuitUiState { + + data class StandardItem( + val title: String, + val imageModel: Any?, + val isSelected: Boolean, + val state: SyncState, + ) + + data class ExpandedItem( + val contentDescription: String, + val imageModel: Any?, + val isExpanded: Boolean, + ) + } +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +internal fun GalleryScreen( + state: GalleryScreen.State, + manager: StorageManager, + modifier: Modifier = Modifier, +) { + val scrollBehavior = enterAlwaysScrollBehavior(rememberTopAppBarState()) + val isSelecting = state.itemList.any { it.isSelected } + val eventSink = state.eventSink + + BottomSheetScaffold( + sheetContent = { GallerySheetContent(eventSink) }, + modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + topBar = { EventsTopBar(stringResource(Res.string.past_events)) }, + floatingActionButton = { + GalleryActionButton( + onClick = OnClick("gallery_capture") { + eventSink(GalleryScreen.Event.Capture.Request) + }, + isActive = state.showCapture, + ) + }, + showDragHandle = false, + ) { paddingValues -> + when { + state.itemList.isEmpty() -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + content = { Text("Empty") }, + ) + } + + else -> { + GalleryGrid( + itemList = state.itemList.toImmutableList(), + onExpand = { eventSink(GalleryScreen.Event.Selection.Expand(it)) }, + onSelect = { eventSink(GalleryScreen.Event.Selection.Toggle(it)) }, + modifier = Modifier + .padding(paddingValues) + .fillMaxSize(), + isSelecting = isSelecting, + ) + + if (state.expandedItem != null) { + GalleryExpandedItem(state.expandedItem) + + BackHandler(enabled = true) { + eventSink(GalleryScreen.Event.Selection.Collapse) + } + } + } + } + + if (state.showCapture) { + ImageCapture(manager) { + when (it) { + is File -> eventSink(GalleryScreen.Event.Capture.Result(it)) + null -> eventSink(GalleryScreen.Event.Capture.Cancel) + } + } + } + } +} + +@Composable +private fun GalleryExpandedItem( + expandedItem: GalleryScreen.State.ExpandedItem, + modifier: Modifier = Modifier, +) { + AnimatedVisibility( + visible = expandedItem.isExpanded, + modifier = modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = { }, + ) + .fillMaxSize(), + enter = fadeIn() + expandIn(expandFrom = Alignment.Center), + exit = fadeOut() + shrinkOut(shrinkTowards = Alignment.Center), + ) { + Image( + painter = rememberAsyncImagePainter(expandedItem.imageModel), + contentDescription = expandedItem.contentDescription, + modifier = Modifier + .background(MaterialTheme.colorScheme.background) + .fillMaxSize(), + contentScale = ContentScale.Fit, + ) + } +} + +@Composable +internal fun GalleryGrid( + itemList: ImmutableList, + onExpand: (Int) -> Unit, + onSelect: (Int) -> Unit, + modifier: Modifier = Modifier, + columnCount: Int = DEFAULT_COLUMN_COUNT, + isSelecting: Boolean = false, +) { + LazyVerticalGrid( + columns = GridCells.Fixed(columnCount), + modifier = modifier, + horizontalArrangement = spacedBy(12.dp), + verticalArrangement = spacedBy(12.dp), + contentPadding = PaddingValues(12.dp), + ) { + itemsIndexed(itemList) { index, item -> + GalleryItem( + item = item, + onSelect = { onSelect(index) }, + onExpand = { onExpand(index) }, + modifier = Modifier.animateItem(), + isSelecting = isSelecting, + ) + } + } +} + +@Composable +@OptIn(ExperimentalFoundationApi::class) +internal fun GalleryItem( + item: GalleryScreen.State.StandardItem, + onSelect: () -> Unit, + onExpand: () -> Unit, + modifier: Modifier = Modifier, + isSelecting: Boolean = false, +) { + val itemBorderRadius by animateDpAsState(if (item.isSelected) 12.dp else 8.dp) + val itemPadding by animateDpAsState(if (item.isSelected) 12.dp else 0.dp) + + Box(modifier) { + Column { + Image( + painter = rememberAsyncImagePainter(item.imageModel), + contentDescription = item.title, + modifier = Modifier.padding(itemPadding) + .clip(RoundedCornerShape(itemBorderRadius)) + .background(Color.DarkGray) + .combinedClickable( + interactionSource = remember { MutableInteractionSource() }, + indication = ripple(bounded = false), + onLongClick = { onSelect() }, + onClick = { if (isSelecting) onSelect() else onExpand() }, + ) + .fillMaxWidth() + .aspectRatio(1f), + contentScale = ContentScale.Crop, + ) + + Text( + text = item.title, + modifier = Modifier.fillMaxWidth(), + style = MaterialTheme.typography.labelSmall, + ) + } + + AnimatedVisibility( + visible = isSelecting, + modifier = Modifier + .align(Alignment.TopStart) + .padding(4.dp), + enter = fadeIn(), + exit = fadeOut(), + ) { + Crossfade(item.isSelected) { state -> + when (state) { + true -> SelectedIndicator( + modifier = Modifier.size(24.dp), + ) + + false -> UnselectedIndicator( + modifier = Modifier + .padding(4.dp) + .size(16.dp), + ) + } + } + } + + AnimatedVisibility( + visible = item.state != SyncState.NOT_SYNCED, + modifier = Modifier.align(Alignment.TopEnd), + enter = fadeIn(), + exit = fadeOut(), + ) { + SyncIndicator(item.state == SyncState.SYNCING) + } + } +} + +@Composable +private fun SelectedIndicator( + modifier: Modifier = Modifier, + surfaceColor: Color = MaterialTheme.colorScheme.surface, + onPrimaryContainerColor: Color = MaterialTheme.colorScheme.onPrimaryContainer, + iconPainter: Painter = rememberVectorPainter(Icons.Filled.CheckCircle), +) { + Canvas(modifier) { + drawCircle( + color = surfaceColor, + ) + + with(iconPainter) { + draw( + size = iconPainter.intrinsicSize, + colorFilter = ColorFilter.tint(onPrimaryContainerColor), + ) + } + } +} + +@Composable +private fun UnselectedIndicator( + modifier: Modifier = Modifier, + highlightColor: Color = Color.White.copy(alpha = 0.5F), + strokeWidth: Dp = 2.dp, +) { + Canvas(modifier) { + drawCircle( + color = highlightColor, + radius = (size.minDimension / 2.0f), + style = Stroke(strokeWidth.toPx()), + ) + } +} + +@Composable +private fun GalleryActionButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + isActive: Boolean = true, + imageVector: ImageVector = Icons.Filled.Add, + contentDescription: String? = "Add", +) { + FloatingActionButton( + onClick = { if (!isActive) onClick() }, + modifier = modifier, + ) { + Crossfade(targetState = isActive) { state -> + when (state) { + true -> CircularProgressIndicator( + modifier = Modifier.size(imageVector.defaultWidth), + ) + + false -> Icon( + imageVector = imageVector, + contentDescription = contentDescription, + ) + } + } + } +} diff --git a/conferences-app/src/commonMain/kotlin/io/ashdavies/party/gallery/GallerySheet.kt b/conferences-app/src/commonMain/kotlin/io/ashdavies/party/gallery/GallerySheet.kt index 03c44b635..f496a2d81 100644 --- a/conferences-app/src/commonMain/kotlin/io/ashdavies/party/gallery/GallerySheet.kt +++ b/conferences-app/src/commonMain/kotlin/io/ashdavies/party/gallery/GallerySheet.kt @@ -10,7 +10,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import io.ashdavies.analytics.OnClick import io.ashdavies.party.material.BottomSheetItem -import io.ashdavies.party.past.GalleryScreen @Composable internal fun GallerySheetContent( diff --git a/conferences-app/src/commonMain/kotlin/io/ashdavies/party/home/HomePresenter.kt b/conferences-app/src/commonMain/kotlin/io/ashdavies/party/home/HomePresenter.kt index cba6037a2..74bf0913f 100644 --- a/conferences-app/src/commonMain/kotlin/io/ashdavies/party/home/HomePresenter.kt +++ b/conferences-app/src/commonMain/kotlin/io/ashdavies/party/home/HomePresenter.kt @@ -11,9 +11,6 @@ import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.screen.Screen import io.ashdavies.identity.IdentityManager import io.ashdavies.identity.IdentityState -import io.ashdavies.party.config.booleanConfigAsState -import io.ashdavies.party.config.showPastEvents -import io.ashdavies.party.past.GalleryScreen import io.ashdavies.party.upcoming.UpcomingEventsScreen import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -25,10 +22,7 @@ internal fun HomePresenter( navigator: Navigator, ): HomeScreen.State { val identityState by identityManager.state.collectAsState(IdentityState.Unauthenticated) - val isHomeEnabled by booleanConfigAsState { showPastEvents() } - - val initialScreen = if (isHomeEnabled) GalleryScreen else UpcomingEventsScreen - var screen by rememberRetained { mutableStateOf(initialScreen) } + var screen by rememberRetained { mutableStateOf(UpcomingEventsScreen) } return HomeScreen.State( identityState = identityState, diff --git a/conferences-app/src/commonMain/kotlin/io/ashdavies/party/home/HomeScreen.kt b/conferences-app/src/commonMain/kotlin/io/ashdavies/party/home/HomeScreen.kt index dce73a132..2b605173a 100644 --- a/conferences-app/src/commonMain/kotlin/io/ashdavies/party/home/HomeScreen.kt +++ b/conferences-app/src/commonMain/kotlin/io/ashdavies/party/home/HomeScreen.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.layout.exclude import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.PhotoLibrary import androidx.compose.material3.BottomAppBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme @@ -27,11 +28,12 @@ import com.slack.circuit.runtime.screen.Screen import io.ashdavies.identity.IdentityState import io.ashdavies.parcelable.Parcelable import io.ashdavies.parcelable.Parcelize -import io.ashdavies.party.animation.FadeVisibility import io.ashdavies.party.config.booleanConfigAsState +import io.ashdavies.party.config.isGalleryEnabled +import io.ashdavies.party.gallery.GalleryScreen import io.ashdavies.party.material.icons.EventList import io.ashdavies.party.material.icons.EventUpcoming -import io.ashdavies.party.past.GalleryScreen +import io.ashdavies.party.past.PastEventsScreen import io.ashdavies.party.upcoming.UpcomingEventsScreen @Parcelize @@ -57,19 +59,16 @@ internal fun HomeScreen( modifier: Modifier = Modifier, reportFullyDrawn: () -> Unit, ) { - val showPastEvents by booleanConfigAsState { true } + val latestReportFullyDrawn by rememberUpdatedState(reportFullyDrawn) val eventSink = state.eventSink Scaffold( modifier = modifier, bottomBar = { - FadeVisibility(showPastEvents) { - HomeBottomBar { screen -> - eventSink(HomeScreen.Event.BottomNav(screen)) - } + HomeBottomBar { screen -> + eventSink(HomeScreen.Event.BottomNav(screen)) } }, - floatingActionButton = { }, contentWindowInsets = ScaffoldDefaults.contentWindowInsets.exclude( insets = TopAppBarDefaults.windowInsets, ), @@ -83,8 +82,6 @@ internal fun HomeScreen( ) } - val latestReportFullyDrawn by rememberUpdatedState(reportFullyDrawn) - LaunchedEffect(Unit) { latestReportFullyDrawn() } @@ -96,6 +93,8 @@ internal fun HomeBottomBar( selected: Screen = HomeScreen, onClick: (Screen) -> Unit = { }, ) { + val isGalleryEnabled by booleanConfigAsState { isGalleryEnabled() } + BottomAppBar(modifier) { NavigationBar { NavigationBarItem( @@ -104,9 +103,17 @@ internal fun HomeBottomBar( icon = { NavigationBarImage(Icons.Outlined.EventUpcoming) }, ) + if (isGalleryEnabled) { + NavigationBarItem( + selected = selected is GalleryScreen, + onClick = { onClick(GalleryScreen) }, + icon = { NavigationBarImage(Icons.Outlined.PhotoLibrary) }, + ) + } + NavigationBarItem( - selected = selected is GalleryScreen, - onClick = { onClick(GalleryScreen) }, + selected = selected is PastEventsScreen, + onClick = { onClick(PastEventsScreen) }, icon = { NavigationBarImage(Icons.Outlined.EventList) }, ) } diff --git a/conferences-app/src/commonMain/kotlin/io/ashdavies/party/material/MaterialThemeKt.kt b/conferences-app/src/commonMain/kotlin/io/ashdavies/party/material/MaterialThemeKt.kt index 8ce14c2a5..70d2b1bb5 100644 --- a/conferences-app/src/commonMain/kotlin/io/ashdavies/party/material/MaterialThemeKt.kt +++ b/conferences-app/src/commonMain/kotlin/io/ashdavies/party/material/MaterialThemeKt.kt @@ -1,5 +1,6 @@ package io.ashdavies.party.material +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.material3.MaterialTheme import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -8,6 +9,12 @@ import androidx.compose.ui.unit.dp internal val MaterialTheme.spacing: MaterialSpacing get() = MaterialSpacing +internal val Spacing.values: PaddingValues + get() = PaddingValues( + horizontal = horizontal, + vertical = vertical, + ) + internal object MaterialSpacing { val small = Spacing( horizontal = 8.dp, diff --git a/conferences-app/src/commonMain/kotlin/io/ashdavies/party/material/Modifier.kt b/conferences-app/src/commonMain/kotlin/io/ashdavies/party/material/Modifier.kt index 405a23b4f..4fe00e2c0 100644 --- a/conferences-app/src/commonMain/kotlin/io/ashdavies/party/material/Modifier.kt +++ b/conferences-app/src/commonMain/kotlin/io/ashdavies/party/material/Modifier.kt @@ -5,7 +5,4 @@ import androidx.compose.runtime.Stable import androidx.compose.ui.Modifier @Stable -internal fun Modifier.padding(spacing: Spacing) = padding( - horizontal = spacing.horizontal, - vertical = spacing.vertical, -) +internal fun Modifier.padding(spacing: Spacing) = padding(spacing.values) diff --git a/conferences-app/src/commonMain/kotlin/io/ashdavies/party/past/PastEventsPresenter.kt b/conferences-app/src/commonMain/kotlin/io/ashdavies/party/past/PastEventsPresenter.kt index 1e8891089..a097533f2 100644 --- a/conferences-app/src/commonMain/kotlin/io/ashdavies/party/past/PastEventsPresenter.kt +++ b/conferences-app/src/commonMain/kotlin/io/ashdavies/party/past/PastEventsPresenter.kt @@ -2,86 +2,34 @@ package io.ashdavies.party.past import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import com.slack.circuit.retained.produceRetainedState -import com.slack.circuit.retained.rememberRetained -import io.ashdavies.party.gallery.File -import io.ashdavies.party.gallery.Image -import io.ashdavies.party.gallery.ImageManager -import io.ashdavies.party.gallery.SyncManager -import io.ashdavies.party.gallery.SyncState -import kotlinx.coroutines.launch +import androidx.compose.runtime.produceState +import io.ashdavies.aggregator.AsgConference +import io.ashdavies.aggregator.callable.PastConferencesCallable +import io.ashdavies.party.events.Event +import kotlinx.collections.immutable.toImmutableList +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import okio.ByteString.Companion.encode @Composable internal fun PastEventsPresenter( - imageManager: ImageManager, - syncManager: SyncManager, -): GalleryScreen.State { - val itemList by produceRetainedState(emptyList()) { - imageManager.list.collect { value = it } + pastConferencesCallable: PastConferencesCallable, +): PastEventsScreen.State { + val itemList by produceState(emptyList()) { + value = pastConferencesCallable(Unit).map { it.toEvent() } } - val syncState by produceRetainedState(emptyMap()) { - syncManager.state.collect { value = it } - } - - val coroutineScope = rememberCoroutineScope() - - var expandedItem by rememberRetained { - mutableStateOf(null) - } - - var selected by rememberRetained { mutableStateOf(emptyList()) } - var takePhoto by rememberRetained { mutableStateOf(false) } - - return GalleryScreen.State( - itemList = itemList.map { - GalleryScreen.State.StandardItem( - title = it.name, - imageModel = File(it.path), - isSelected = it in selected, - state = syncState[it.name] ?: SyncState.NOT_SYNCED, - ) - }, - expandedItem = expandedItem, - showCapture = takePhoto, - ) { event -> - when (event) { - is GalleryScreen.Event.Capture.Result -> coroutineScope.launch { - imageManager.add(event.value) - takePhoto = false - } - - is GalleryScreen.Event.Capture.Cancel -> takePhoto = false - is GalleryScreen.Event.Capture.Request -> takePhoto = true - - is GalleryScreen.Event.Selection.Expand -> { - expandedItem = GalleryScreen.State.ExpandedItem( - contentDescription = itemList[event.index].name, - imageModel = File(itemList[event.index].path), - isExpanded = true, - ) - } - - is GalleryScreen.Event.Selection.Toggle -> itemList[event.index].also { - if (it in selected) selected -= it else selected += it - } - - is GalleryScreen.Event.Selection.Collapse -> { - expandedItem = expandedItem?.copy(isExpanded = false) - } + return PastEventsScreen.State( + itemList = itemList.toImmutableList(), + ) +} - is GalleryScreen.Event.Selection.Delete -> coroutineScope.launch { - selected.forEach { imageManager.remove(it) } - selected = emptyList() - } +internal fun AsgConference.toEvent(): Event = Event( + id = hash(), name = name, website = website, location = location, dateStart = dateStart, + dateEnd = dateEnd, imageUrl = imageUrl, status = status, online = online, + cfpStart = cfp?.start, cfpEnd = cfp?.end, cfpSite = cfp?.site, +) - is GalleryScreen.Event.Selection.Sync -> coroutineScope.launch { - selected.forEach { syncManager.sync(it.path) } - selected = emptyList() - } - } - } -} +private inline fun T.hash() = Json + .encodeToString(this) + .encode().md5().hex() diff --git a/conferences-app/src/commonMain/kotlin/io/ashdavies/party/past/PastEventsScreen.kt b/conferences-app/src/commonMain/kotlin/io/ashdavies/party/past/PastEventsScreen.kt index eaaf1fb4d..f3eeeacf6 100644 --- a/conferences-app/src/commonMain/kotlin/io/ashdavies/party/past/PastEventsScreen.kt +++ b/conferences-app/src/commonMain/kotlin/io/ashdavies/party/past/PastEventsScreen.kt @@ -1,385 +1,116 @@ package io.ashdavies.party.past -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.Crossfade -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.expandIn -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkOut -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Arrangement.spacedBy -import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.exclude +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size 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.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.CheckCircle -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.Icon +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material3.BottomAppBarDefaults +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.ScaffoldDefaults +import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBarDefaults.enterAlwaysScrollBehavior -import androidx.compose.material3.rememberTopAppBarState -import androidx.compose.material3.ripple +import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -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.drawscope.Stroke -import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.unit.Dp +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import coil3.compose.rememberAsyncImagePainter -import com.slack.circuit.foundation.internal.BackHandler -import com.slack.circuit.runtime.CircuitUiEvent import com.slack.circuit.runtime.CircuitUiState import com.slack.circuit.runtime.screen.Screen -import io.ashdavies.analytics.OnClick import io.ashdavies.parcelable.Parcelable import io.ashdavies.parcelable.Parcelize -import io.ashdavies.party.animation.FadeVisibility -import io.ashdavies.party.config.booleanConfigAsState -import io.ashdavies.party.config.galleryCapture +import io.ashdavies.party.events.Event import io.ashdavies.party.events.EventsTopBar -import io.ashdavies.party.gallery.File -import io.ashdavies.party.gallery.GallerySheetContent -import io.ashdavies.party.gallery.ImageCapture -import io.ashdavies.party.gallery.StorageManager -import io.ashdavies.party.gallery.SyncIndicator -import io.ashdavies.party.gallery.SyncState -import io.ashdavies.party.material.BottomSheetScaffold +import io.ashdavies.party.material.LocalWindowSizeClass +import io.ashdavies.party.material.padding +import io.ashdavies.party.material.spacing +import io.ashdavies.party.material.values import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.toImmutableList import org.jetbrains.compose.resources.stringResource import playground.conferences_app.generated.resources.Res import playground.conferences_app.generated.resources.past_events -private const val DEFAULT_COLUMN_COUNT = 4 +internal object PastEventsDefaults { + const val ASPECT_RATIO = 3 / 1f +} @Parcelize -internal object GalleryScreen : Parcelable, Screen { - sealed interface Event : CircuitUiEvent { - sealed interface Capture : Event { - data class Result(val value: File) : Capture - - data object Cancel : Capture - data object Request : Capture - } - - sealed interface Selection : Event { - data class Expand(val index: Int) : Selection - data class Toggle(val index: Int) : Selection - - data object Collapse : Selection - data object Delete : Selection - data object Sync : Selection - } - } - - internal data class State( - val itemList: List = emptyList(), - val expandedItem: ExpandedItem? = null, - val showCapture: Boolean = false, - val eventSink: (Event) -> Unit, - ) : CircuitUiState { - - data class StandardItem( - val title: String, - val imageModel: Any?, - val isSelected: Boolean, - val state: SyncState, - ) - - data class ExpandedItem( - val contentDescription: String, - val imageModel: Any?, - val isExpanded: Boolean, - ) - } +internal object PastEventsScreen : Parcelable, Screen { + data class State(val itemList: ImmutableList) : CircuitUiState } @Composable -@OptIn(ExperimentalMaterial3Api::class) -internal fun PastEventListScreen( - state: GalleryScreen.State, - manager: StorageManager, +internal fun PastEventsScreen( + state: PastEventsScreen.State, modifier: Modifier = Modifier, ) { - val isGalleryCaptureEnabled by booleanConfigAsState { galleryCapture() } - val scrollBehavior = enterAlwaysScrollBehavior(rememberTopAppBarState()) - val isSelecting = state.itemList.any { it.isSelected } - val eventSink = state.eventSink - - BottomSheetScaffold( - sheetContent = { GallerySheetContent(eventSink) }, - modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), + Scaffold( + modifier = modifier, topBar = { EventsTopBar(stringResource(Res.string.past_events)) }, - floatingActionButton = { - FadeVisibility(isGalleryCaptureEnabled) { - GalleryActionButton( - onClick = OnClick("gallery_capture") { - eventSink(GalleryScreen.Event.Capture.Request) - }, - isActive = state.showCapture, - ) - } - }, - showDragHandle = false, - ) { paddingValues -> - when { - state.itemList.isEmpty() -> { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - content = { Text("Empty") }, - ) - } - - else -> { - GalleryGrid( - itemList = state.itemList.toImmutableList(), - onExpand = { eventSink(GalleryScreen.Event.Selection.Expand(it)) }, - onSelect = { eventSink(GalleryScreen.Event.Selection.Toggle(it)) }, + contentWindowInsets = ScaffoldDefaults.contentWindowInsets.exclude( + insets = BottomAppBarDefaults.windowInsets, + ), + ) { contentPadding -> + LazyVerticalGrid( + columns = GridCells.Fixed( + count = when (LocalWindowSizeClass.current.widthSizeClass) { + WindowWidthSizeClass.Compact -> 3 + else -> 5 + }, + ), + modifier = Modifier.padding(contentPadding), + contentPadding = MaterialTheme.spacing.large.values, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.small.vertical), + horizontalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.small.horizontal), + ) { + items(state.itemList) { event -> + EventItemContent( + name = event.name, modifier = Modifier - .padding(paddingValues) - .fillMaxSize(), - isSelecting = isSelecting, + .aspectRatio(PastEventsDefaults.ASPECT_RATIO) + .animateItem(), ) - - if (state.expandedItem != null) { - GalleryExpandedItem(state.expandedItem) - - BackHandler(enabled = true) { - eventSink(GalleryScreen.Event.Selection.Collapse) - } - } } } - - if (state.showCapture) { - ImageCapture(manager) { - when (it) { - is File -> eventSink(GalleryScreen.Event.Capture.Result(it)) - null -> eventSink(GalleryScreen.Event.Capture.Cancel) - } - } - } - } -} - -@Composable -private fun GalleryExpandedItem( - expandedItem: GalleryScreen.State.ExpandedItem, - modifier: Modifier = Modifier, -) { - AnimatedVisibility( - visible = expandedItem.isExpanded, - modifier = modifier - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = { }, - ) - .fillMaxSize(), - enter = fadeIn() + expandIn(expandFrom = Alignment.Center), - exit = fadeOut() + shrinkOut(shrinkTowards = Alignment.Center), - ) { - Image( - painter = rememberAsyncImagePainter(expandedItem.imageModel), - contentDescription = expandedItem.contentDescription, - modifier = Modifier - .background(MaterialTheme.colorScheme.background) - .fillMaxSize(), - contentScale = ContentScale.Fit, - ) } } @Composable -internal fun GalleryGrid( - itemList: ImmutableList, - onExpand: (Int) -> Unit, - onSelect: (Int) -> Unit, +private fun EventItemContent( + name: String, modifier: Modifier = Modifier, - columnCount: Int = DEFAULT_COLUMN_COUNT, - isSelecting: Boolean = false, ) { - LazyVerticalGrid( - columns = GridCells.Fixed(columnCount), + Surface( modifier = modifier, - horizontalArrangement = spacedBy(12.dp), - verticalArrangement = spacedBy(12.dp), - contentPadding = PaddingValues(12.dp), + shape = MaterialTheme.shapes.small, + color = Color.Transparent, + border = BorderStroke( + width = 1.0.dp, + color = MaterialTheme.colorScheme.outline, + ), ) { - itemsIndexed(itemList) { index, item -> - GalleryItem( - item = item, - onSelect = { onSelect(index) }, - onExpand = { onExpand(index) }, - modifier = Modifier.animateItem(), - isSelecting = isSelecting, - ) - } - } -} - -@Composable -@OptIn(ExperimentalFoundationApi::class) -internal fun GalleryItem( - item: GalleryScreen.State.StandardItem, - onSelect: () -> Unit, - onExpand: () -> Unit, - modifier: Modifier = Modifier, - isSelecting: Boolean = false, -) { - val itemBorderRadius by animateDpAsState(if (item.isSelected) 12.dp else 8.dp) - val itemPadding by animateDpAsState(if (item.isSelected) 12.dp else 0.dp) - - Box(modifier) { - Column { - Image( - painter = rememberAsyncImagePainter(item.imageModel), - contentDescription = item.title, - modifier = Modifier.padding(itemPadding) - .clip(RoundedCornerShape(itemBorderRadius)) - .background(Color.DarkGray) - .combinedClickable( - interactionSource = remember { MutableInteractionSource() }, - indication = ripple(bounded = false), - onLongClick = { onSelect() }, - onClick = { if (isSelecting) onSelect() else onExpand() }, - ) - .fillMaxWidth() - .aspectRatio(1f), - contentScale = ContentScale.Crop, - ) - + Column( + modifier = Modifier.fillMaxHeight(), + verticalArrangement = Arrangement.Center, + ) { Text( - text = item.title, - modifier = Modifier.fillMaxWidth(), + text = name, + modifier = Modifier + .padding(MaterialTheme.spacing.small) + .fillMaxWidth(), + color = LocalContentColor.current, + textAlign = TextAlign.Center, style = MaterialTheme.typography.labelSmall, ) } - - AnimatedVisibility( - visible = isSelecting, - modifier = Modifier - .align(Alignment.TopStart) - .padding(4.dp), - enter = fadeIn(), - exit = fadeOut(), - ) { - Crossfade(item.isSelected) { state -> - when (state) { - true -> SelectedIndicator( - modifier = Modifier.size(24.dp), - ) - - false -> UnselectedIndicator( - modifier = Modifier - .padding(4.dp) - .size(16.dp), - ) - } - } - } - - AnimatedVisibility( - visible = item.state != SyncState.NOT_SYNCED, - modifier = Modifier.align(Alignment.TopEnd), - enter = fadeIn(), - exit = fadeOut(), - ) { - SyncIndicator(item.state == SyncState.SYNCING) - } - } -} - -@Composable -private fun SelectedIndicator( - modifier: Modifier = Modifier, - surfaceColor: Color = MaterialTheme.colorScheme.surface, - onPrimaryContainerColor: Color = MaterialTheme.colorScheme.onPrimaryContainer, - iconPainter: Painter = rememberVectorPainter(Icons.Filled.CheckCircle), -) { - Canvas(modifier) { - drawCircle( - color = surfaceColor, - ) - - with(iconPainter) { - draw( - size = iconPainter.intrinsicSize, - colorFilter = ColorFilter.tint(onPrimaryContainerColor), - ) - } - } -} - -@Composable -private fun UnselectedIndicator( - modifier: Modifier = Modifier, - highlightColor: Color = Color.White.copy(alpha = 0.5F), - strokeWidth: Dp = 2.dp, -) { - Canvas(modifier) { - drawCircle( - color = highlightColor, - radius = (size.minDimension / 2.0f), - style = Stroke(strokeWidth.toPx()), - ) - } -} - -@Composable -private fun GalleryActionButton( - onClick: () -> Unit, - modifier: Modifier = Modifier, - isActive: Boolean = true, - imageVector: ImageVector = Icons.Filled.Add, - contentDescription: String? = "Add", -) { - FloatingActionButton( - onClick = { if (!isActive) onClick() }, - modifier = modifier, - ) { - Crossfade(targetState = isActive) { state -> - when (state) { - true -> CircularProgressIndicator( - modifier = Modifier.size(imageVector.defaultWidth), - ) - - false -> Icon( - imageVector = imageVector, - contentDescription = contentDescription, - ) - } - } } } diff --git a/conferences-app/src/screenshotTest/kotlin/io/ashdavies/party/gallery/GalleryScreenTests.kt b/conferences-app/src/screenshotTest/kotlin/io/ashdavies/party/gallery/GalleryScreenTests.kt index ff7f5c55e..02d81c036 100644 --- a/conferences-app/src/screenshotTest/kotlin/io/ashdavies/party/gallery/GalleryScreenTests.kt +++ b/conferences-app/src/screenshotTest/kotlin/io/ashdavies/party/gallery/GalleryScreenTests.kt @@ -1,9 +1,6 @@ package io.ashdavies.party.gallery -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.runtime.Composable -import io.ashdavies.party.past.GalleryGrid -import io.ashdavies.party.past.GalleryScreen import io.ashdavies.party.tooling.MaterialPreviewTheme import io.ashdavies.party.tooling.PreviewDayNight import kotlinx.collections.immutable.persistentListOf @@ -12,7 +9,6 @@ internal class GalleryScreenTests { @Composable @PreviewDayNight - @OptIn(ExperimentalFoundationApi::class) private fun GalleryGridPreview() { MaterialPreviewTheme { GalleryGrid(