diff --git a/gallery-app/src/androidDebug/kotlin/io/ashdavies/gallery/GalleryScreen.preview.kt b/gallery-app/src/androidDebug/kotlin/io/ashdavies/gallery/GalleryScreen.preview.kt new file mode 100644 index 000000000..47c882f32 --- /dev/null +++ b/gallery-app/src/androidDebug/kotlin/io/ashdavies/gallery/GalleryScreen.preview.kt @@ -0,0 +1,84 @@ +package io.ashdavies.gallery + +import android.content.res.Configuration +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.TopAppBarDefaults.enterAlwaysScrollBehavior +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview +import kotlinx.collections.immutable.persistentListOf + +@Preview +@Composable +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@OptIn(ExperimentalMaterial3Api::class) +internal fun GalleryTopAppBarPreview() { + GalleryPreviewTheme { + GalleryTopAppBar(enterAlwaysScrollBehavior()) { } + } +} + +@Composable +@Preview(heightDp = 120) +@OptIn(ExperimentalFoundationApi::class) +@Preview(heightDp = 120, uiMode = Configuration.UI_MODE_NIGHT_YES) +internal fun GalleryGridPreview() { + GalleryPreviewTheme { + Surface { + GalleryGrid( + itemList = persistentListOf( + GalleryScreenStateItem(), + GalleryScreenStateItem(isSelected = true), + GalleryScreenStateItem(state = SyncState.SYNCING), + GalleryScreenStateItem(state = SyncState.SYNCED), + ), + isSelecting = true, + ) { } + } + } +} + +@Preview +@Composable +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +internal fun GalleryBottomBarSelectingPreview() { + GalleryPreviewTheme { + GalleryBottomBar( + state = GalleryScreenState(), + isSelecting = true, + ) + } +} + +@Composable +private fun GalleryPreviewTheme(content: @Composable () -> Unit) { + MaterialTheme(if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()) { + content() + } +} + +private fun GalleryScreenState( + itemList: List = emptyList(), + showCapture: Boolean = false, + isLoggedIn: Boolean = false, +) = GalleryScreen.State( + itemList = itemList, + showCapture = showCapture, + isLoggedIn = isLoggedIn, +) { } + +private fun GalleryScreenStateItem( + name: String = "Sample Image", + isSelected: Boolean = false, + state: SyncState = SyncState.NOT_SYNCED, +) = GalleryScreen.State.Item( + name = name, + imageModel = "https://picsum.photos/200", + isSelected = isSelected, + state = state, +) diff --git a/gallery-app/src/androidDebug/res/drawable/alanya.webp b/gallery-app/src/androidDebug/res/drawable/alanya.webp new file mode 100644 index 000000000..5673074e9 Binary files /dev/null and b/gallery-app/src/androidDebug/res/drawable/alanya.webp differ diff --git a/gallery-app/src/androidDebug/res/drawable/camelford.webp b/gallery-app/src/androidDebug/res/drawable/camelford.webp new file mode 100644 index 000000000..f0ee0ab9f Binary files /dev/null and b/gallery-app/src/androidDebug/res/drawable/camelford.webp differ diff --git a/gallery-app/src/androidDebug/res/drawable/tende.webp b/gallery-app/src/androidDebug/res/drawable/tende.webp new file mode 100644 index 000000000..f31aa0f9a Binary files /dev/null and b/gallery-app/src/androidDebug/res/drawable/tende.webp differ diff --git a/gallery-app/src/androidMain/kotlin/io/ashdavies/gallery/GalleryScreen.preview.kt b/gallery-app/src/androidMain/kotlin/io/ashdavies/gallery/GalleryScreen.preview.kt deleted file mode 100644 index 00f0760be..000000000 --- a/gallery-app/src/androidMain/kotlin/io/ashdavies/gallery/GalleryScreen.preview.kt +++ /dev/null @@ -1,40 +0,0 @@ -package io.ashdavies.gallery - -import androidx.compose.runtime.Composable -import androidx.compose.ui.tooling.preview.Preview -import io.ashdavies.content.PlatformContext - -private val DefaultState = GalleryScreen.State( - itemList = emptyList(), - showCapture = false, - isLoggedIn = false, - eventSink = { }, -) - -private val EmptyStorageManager = object : StorageManager { - override fun create(context: PlatformContext): File = TempFile - override fun delete(file: File): Boolean = false -} - -private val TempFile = File.createTempFile( - /* prefix = */ "tmp", - /* suffix = */ null, -) - -@Preview -@Composable -internal fun GalleryCaptureDefaultPreview() { - GalleryCapture(EmptyStorageManager) { } -} - -@Preview -@Composable -internal fun GalleryBottomBarDefaultPreview() { - GalleryBottomBar(DefaultState, isSelecting = false) -} - -@Preview -@Composable -internal fun GalleryBottomBarSelectingPreview() { - GalleryBottomBar(DefaultState, isSelecting = true) -} diff --git a/gallery-app/src/commonMain/kotlin/io/ashdavies/gallery/GalleryPresenter.kt b/gallery-app/src/commonMain/kotlin/io/ashdavies/gallery/GalleryPresenter.kt index 5c373e48b..973d6df88 100644 --- a/gallery-app/src/commonMain/kotlin/io/ashdavies/gallery/GalleryPresenter.kt +++ b/gallery-app/src/commonMain/kotlin/io/ashdavies/gallery/GalleryPresenter.kt @@ -42,7 +42,7 @@ public object GalleryScreen : Parcelable, Screen { data class Item( val name: String, - val file: File, + val imageModel: Any?, val isSelected: Boolean, val state: SyncState, ) @@ -105,7 +105,7 @@ internal fun GalleryPresenter( itemList = itemList.map { GalleryScreen.State.Item( name = it.name, - file = File(it.path), + imageModel = File(it.path), isSelected = it in selected, state = syncState[it.name] ?: SyncState.NOT_SYNCED, ) diff --git a/gallery-app/src/commonMain/kotlin/io/ashdavies/gallery/GalleryScreen.kt b/gallery-app/src/commonMain/kotlin/io/ashdavies/gallery/GalleryScreen.kt index eb20d14d2..30b34482a 100644 --- a/gallery-app/src/commonMain/kotlin/io/ashdavies/gallery/GalleryScreen.kt +++ b/gallery-app/src/commonMain/kotlin/io/ashdavies/gallery/GalleryScreen.kt @@ -1,6 +1,7 @@ package io.ashdavies.gallery import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.Crossfade import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.LinearEasing @@ -35,6 +36,7 @@ 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.AccountCircle import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Delete @@ -61,14 +63,18 @@ 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.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.rotate import androidx.compose.ui.draw.scale -import androidx.compose.ui.geometry.Offset 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.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.IntOffset import androidx.compose.ui.unit.dp import io.ashdavies.graphics.rememberAsyncImagePainter @@ -82,7 +88,10 @@ private val Color.Companion.Orange: Color get() = Color(0xFFFFA500) @Composable -@OptIn(ExperimentalMaterial3Api::class) +@OptIn( + ExperimentalFoundationApi::class, + ExperimentalMaterial3Api::class, +) internal fun GalleryScreen( state: GalleryScreen.State, manager: StorageManager, @@ -90,39 +99,47 @@ internal fun GalleryScreen( ) { val scrollBehavior = enterAlwaysScrollBehavior(rememberTopAppBarState()) val isSelecting = state.itemList.any { it.isSelected } + val eventSink = state.eventSink Scaffold( modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), - topBar = { GalleryTopAppBar(scrollBehavior) }, + topBar = { GalleryTopAppBar(scrollBehavior) { } }, bottomBar = { GalleryBottomBar(state, isSelecting) }, ) { paddingValues -> when { - state.itemList.isEmpty() -> GalleryEmpty() + state.itemList.isEmpty() -> { + Box( + modifier = modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + content = { Text("Empty") }, + ) + } + else -> { GalleryGrid( itemList = state.itemList.toImmutableList(), isSelecting = isSelecting, modifier = Modifier.padding(paddingValues), - onSelect = { state.eventSink(GalleryScreen.Event.Toggle(it)) }, + onSelect = { eventSink(GalleryScreen.Event.Toggle(it)) }, ) + } + } - if (state.showCapture) { - GalleryCapture( - manager = manager, - eventSink = state.eventSink, - ) - } + if (state.showCapture) { + ImageCapture(manager) { + eventSink(GalleryScreen.Event.Result(it)) } } } } @Composable -@OptIn(ExperimentalMaterial3Api::class) +@ExperimentalMaterial3Api internal fun GalleryTopAppBar( scrollBehavior: TopAppBarScrollBehavior, title: String = "Gallery", modifier: Modifier = Modifier, + onProfileClick: (Boolean) -> Unit, ) { CenterAlignedTopAppBar( title = { @@ -133,6 +150,7 @@ internal fun GalleryTopAppBar( ) }, modifier = modifier, + actions = { ProfileActionButton { onProfileClick(false) } }, colors = TopAppBarDefaults.centerAlignedTopAppBarColors( containerColor = MaterialTheme.colorScheme.background, ), @@ -141,17 +159,33 @@ internal fun GalleryTopAppBar( } @Composable -internal fun GalleryEmpty(modifier: Modifier = Modifier) { - Box( - modifier = modifier.fillMaxSize(), - contentAlignment = Alignment.Center, - ) { - Text("Empty") +private fun ProfileActionButton( + isLoggedIn: Boolean = false, + onClick: () -> Unit, +) { + Crossfade(targetState = isLoggedIn) { state -> + when (state) { + true -> IconButton(onClick = onClick) { + Image( + imageVector = Icons.Filled.AccountCircle, + contentDescription = "Logout", + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground), + ) + } + + false -> IconButton(onClick = onClick) { + Image( + imageVector = Icons.Filled.AccountCircle, + contentDescription = "Login", + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground), + ) + } + } } } @Composable -@OptIn(ExperimentalFoundationApi::class) +@ExperimentalFoundationApi internal fun GalleryGrid( itemList: ImmutableList, isSelecting: Boolean = false, @@ -166,77 +200,125 @@ internal fun GalleryGrid( contentPadding = PaddingValues(12.dp), ) { itemsIndexed(itemList) { index, item -> - val itemBorderRadius by animateDpAsState(if (item.isSelected) 12.dp else 8.dp) - val itemPadding by animateDpAsState(if (item.isSelected) 12.dp else 0.dp) + GalleryItem( + item = item, + isSelecting = isSelecting, + modifier = Modifier.animateItemPlacement(), + onSelect = { onSelect(index) }, + ) + } + } +} - Box(Modifier.animateItemPlacement()) { - Column { - Image( - painter = rememberAsyncImagePainter(item.file), - contentDescription = item.name, - modifier = Modifier.padding(itemPadding) - .clip(RoundedCornerShape(itemBorderRadius)) - .background(Color.DarkGray) - .combinedClickable( - interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(bounded = false), - onLongClick = { onSelect(index) }, - onClick = { if (isSelecting) onSelect(index) }, - ) - .fillMaxWidth() - .aspectRatio(1f), - contentScale = ContentScale.Crop, - ) +@Composable +@ExperimentalFoundationApi +internal fun GalleryItem( + item: GalleryScreen.State.Item, + isSelecting: Boolean = false, + modifier: Modifier = Modifier, + onSelect: () -> Unit, +) { + val itemBorderRadius by animateDpAsState(if (item.isSelected) 12.dp else 8.dp) + val itemPadding by animateDpAsState(if (item.isSelected) 12.dp else 0.dp) - Text( - text = item.name, - modifier = Modifier.fillMaxWidth(), - style = MaterialTheme.typography.labelSmall, + Box(modifier) { + Column { + Image( + painter = rememberAsyncImagePainter(item.imageModel), + contentDescription = item.name, + modifier = Modifier.padding(itemPadding) + .clip(RoundedCornerShape(itemBorderRadius)) + .background(Color.DarkGray) + .combinedClickable( + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(bounded = false), + onLongClick = { onSelect() }, + onClick = { if (isSelecting) onSelect() }, ) - } + .fillMaxWidth() + .aspectRatio(1f), + contentScale = ContentScale.Crop, + ) - AnimatedVisibility( - visible = item.isSelected, - modifier = Modifier.align(Alignment.TopStart), - enter = fadeIn(), - exit = fadeOut(), - ) { - Icon( - imageVector = Icons.Filled.CheckCircle, - contentDescription = null, - modifier = Modifier.padding(4.dp), + Text( + text = item.name, + 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), ) - Canvas( + false -> UnselectedIndicator( modifier = Modifier .padding(4.dp) - .size(24.dp), - ) { - drawCircle( - color = if (item.isSelected) Color.DarkGray else Color.White, - radius = (size.minDimension / 2) - if (item.isSelected) 0 else 8, - center = Offset(x = size.width / 2, y = size.height / 2), - alpha = if (item.isSelected) 1.0f else 0.5f, - style = Stroke(if (item.isSelected) 8f else 6f), - ) - } - } - - AnimatedVisibility( - visible = item.state != SyncState.NOT_SYNCED, - modifier = Modifier.align(Alignment.TopEnd), - enter = fadeIn(), - exit = fadeOut(), - ) { - SyncIndicator(item.state == SyncState.SYNCING) + .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( + surfaceColor: Color = MaterialTheme.colorScheme.surface, + onPrimaryContainerColor: Color = MaterialTheme.colorScheme.onPrimaryContainer, + iconPainter: Painter = rememberVectorPainter(Icons.Filled.CheckCircle), + modifier: Modifier = Modifier, +) { + Canvas(modifier) { + drawCircle( + color = surfaceColor, + ) + + with(iconPainter) { + draw( + size = iconPainter.intrinsicSize, + colorFilter = ColorFilter.tint(onPrimaryContainerColor), + ) + } } } @Composable -internal fun SyncIndicator(isSyncing: Boolean, modifier: Modifier = Modifier) { +private fun UnselectedIndicator( + highlightColor: Color = Color.White.copy(alpha = 0.5F), + strokeWidth: Dp = 2.dp, + modifier: Modifier = Modifier, +) { + Canvas(modifier) { + drawCircle( + color = highlightColor, + radius = (size.minDimension / 2.0f), + style = Stroke(strokeWidth.toPx()), + ) + } +} + +@Composable +private fun SyncIndicator(isSyncing: Boolean, modifier: Modifier = Modifier) { val tint by animateColorAsState(if (isSyncing) Color.Orange else Color.LightGreen) val scale by animateFloatAsState(if (isSyncing) 0.75f else 1f) @@ -283,23 +365,10 @@ internal fun SyncIndicator(isSyncing: Boolean, modifier: Modifier = Modifier) { ) } -@Composable -internal fun GalleryCapture( - manager: StorageManager, - modifier: Modifier = Modifier, - eventSink: (GalleryScreen.Event) -> Unit, -) { - ImageCapture( - manager = manager, - modifier = modifier, - onCapture = { eventSink(GalleryScreen.Event.Result(it)) }, - ) -} - @Composable internal fun GalleryBottomBar( state: GalleryScreen.State, - isSelecting: Boolean, + isSelecting: Boolean = false, modifier: Modifier = Modifier, ) { val eventSink = state.eventSink