Skip to content

Commit 27cd2b4

Browse files
committed
feat(presentation): enhance gallery with selection, editor and shared transitions
- Add GalleryItemStateEvent - Add SharedTransitionProvider for navigation - Add gallery editor components - Add gallery list selection support - Add scaffold widgets - Add new UI components - Update GalleryScreen with multi-selection - Update GalleryDetailScreen with enhanced features - Update GalleryViewModel with batch operations - Update navigation graphs and routers - Update FalAiGenerationScreen - Update ImageToImageScreen and TextToImageScreen - Update SettingsScreen - Update HomeNavigationScreen and DrawerScreen - Update ZoomableImage widget - Update BackgroundWorkWidget - Add/update ViewModel tests
1 parent 55826b3 commit 27cd2b4

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

50 files changed

+4051
-981
lines changed

presentation/src/main/java/dev/minios/pdaiv1/presentation/activity/AiStableDiffusionActivity.kt

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import dev.minios.pdaiv1.core.common.log.debugLog
2626
import dev.minios.pdaiv1.presentation.extensions.navigatePopUpToCurrent
2727
import dev.minios.pdaiv1.presentation.navigation.NavigationEffect
2828
import dev.minios.pdaiv1.presentation.navigation.NavigationRoute
29+
import dev.minios.pdaiv1.presentation.navigation.SharedTransitionProvider
2930
import dev.minios.pdaiv1.presentation.navigation.graph.mainNavGraph
3031
import dev.minios.pdaiv1.presentation.screen.drawer.DrawerScreen
3132
import dev.minios.pdaiv1.presentation.theme.global.AiSdAppTheme
@@ -129,19 +130,21 @@ class AiStableDiffusionActivity : AppCompatActivity() {
129130
}
130131
}
131132
) { state ->
132-
DrawerScreen(
133-
drawerState = drawerState,
134-
backStackEntry = backStackEntry,
135-
homeRouteEntry = homeRouteEntry,
136-
onRootNavigate = navController::navigate,
137-
onHomeNavigate = { viewModel.processIntent(AppIntent.HomeRoute(it)) },
138-
navItems = state.drawerItems,
139-
) {
140-
NavHost(
141-
navController = navController,
142-
startDestination = NavigationRoute.Splash,
143-
builder = { mainNavGraph() },
144-
)
133+
SharedTransitionProvider {
134+
DrawerScreen(
135+
drawerState = drawerState,
136+
backStackEntry = backStackEntry,
137+
homeRouteEntry = homeRouteEntry,
138+
onRootNavigate = navController::navigate,
139+
onHomeNavigate = { viewModel.processIntent(AppIntent.HomeRoute(it)) },
140+
navItems = state.drawerItems,
141+
) {
142+
NavHost(
143+
navController = navController,
144+
startDestination = NavigationRoute.Splash,
145+
builder = { mainNavGraph() },
146+
)
147+
}
145148
}
146149
}
147150
}
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
package dev.minios.pdaiv1.presentation.components
2+
3+
import androidx.compose.animation.AnimatedVisibility
4+
import androidx.compose.animation.fadeIn
5+
import androidx.compose.animation.fadeOut
6+
import androidx.compose.foundation.background
7+
import androidx.compose.foundation.gestures.detectVerticalDragGestures
8+
import androidx.compose.foundation.layout.Box
9+
import androidx.compose.foundation.layout.fillMaxHeight
10+
import androidx.compose.foundation.layout.height
11+
import androidx.compose.foundation.layout.offset
12+
import androidx.compose.foundation.layout.padding
13+
import androidx.compose.foundation.layout.width
14+
import androidx.compose.foundation.lazy.grid.LazyGridState
15+
import androidx.compose.foundation.shape.RoundedCornerShape
16+
import androidx.compose.material3.MaterialTheme
17+
import androidx.compose.runtime.Composable
18+
import androidx.compose.runtime.derivedStateOf
19+
import androidx.compose.runtime.getValue
20+
import androidx.compose.runtime.mutableFloatStateOf
21+
import androidx.compose.runtime.mutableStateOf
22+
import androidx.compose.runtime.remember
23+
import androidx.compose.runtime.rememberCoroutineScope
24+
import androidx.compose.runtime.setValue
25+
import androidx.compose.ui.Alignment
26+
import androidx.compose.ui.Modifier
27+
import androidx.compose.ui.draw.clip
28+
import androidx.compose.ui.input.pointer.pointerInput
29+
import androidx.compose.ui.layout.onSizeChanged
30+
import androidx.compose.ui.platform.LocalDensity
31+
import androidx.compose.ui.unit.IntOffset
32+
import androidx.compose.ui.unit.dp
33+
import kotlinx.coroutines.launch
34+
import kotlin.math.roundToInt
35+
36+
/**
37+
* Draggable scrollbar for fast navigation in gallery grid.
38+
* Shows current scroll position and allows quick scrolling by dragging.
39+
*/
40+
@Composable
41+
fun DraggableScrollbar(
42+
lazyGridState: LazyGridState,
43+
totalItems: Int,
44+
columns: Int,
45+
modifier: Modifier = Modifier,
46+
) {
47+
val density = LocalDensity.current
48+
val coroutineScope = rememberCoroutineScope()
49+
50+
var containerHeight by remember { mutableFloatStateOf(0f) }
51+
var isDragging by remember { mutableStateOf(false) }
52+
var dragThumbOffset by remember { mutableFloatStateOf(0f) }
53+
54+
val thumbHeightDp = 48.dp
55+
val thumbHeight = with(density) { thumbHeightDp.toPx() }
56+
57+
// Show scrollbar only when there are enough items
58+
val showScrollbar by remember(totalItems, columns) {
59+
derivedStateOf { totalItems > columns * 3 }
60+
}
61+
62+
// Calculate scroll progress (0 to 1) based on grid state
63+
val scrollProgress by remember(lazyGridState) {
64+
derivedStateOf {
65+
if (totalItems == 0 || columns == 0) return@derivedStateOf 0f
66+
67+
val totalRows = (totalItems + columns - 1) / columns
68+
val firstVisibleRow = lazyGridState.firstVisibleItemIndex / columns
69+
70+
if (totalRows <= 1) 0f
71+
else (firstVisibleRow.toFloat() / (totalRows - 1).coerceAtLeast(1))
72+
.coerceIn(0f, 1f)
73+
}
74+
}
75+
76+
AnimatedVisibility(
77+
visible = showScrollbar,
78+
enter = fadeIn(),
79+
exit = fadeOut(),
80+
modifier = modifier,
81+
) {
82+
Box(
83+
modifier = Modifier
84+
.fillMaxHeight()
85+
.width(24.dp)
86+
.padding(vertical = 8.dp, horizontal = 4.dp)
87+
.onSizeChanged { containerHeight = it.height.toFloat() }
88+
.pointerInput(totalItems, columns) {
89+
detectVerticalDragGestures(
90+
onDragStart = { offset ->
91+
isDragging = true
92+
// Initialize drag offset from current scroll position
93+
val maxThumbOffset = (containerHeight - thumbHeight).coerceAtLeast(0f)
94+
dragThumbOffset = scrollProgress * maxThumbOffset
95+
},
96+
onDragEnd = { isDragging = false },
97+
onDragCancel = { isDragging = false },
98+
onVerticalDrag = { change, dragAmount ->
99+
change.consume()
100+
101+
val maxThumbOffset = (containerHeight - thumbHeight).coerceAtLeast(0f)
102+
103+
// Update drag offset directly
104+
dragThumbOffset = (dragThumbOffset + dragAmount)
105+
.coerceIn(0f, maxThumbOffset)
106+
107+
val newProgress = if (maxThumbOffset > 0) {
108+
dragThumbOffset / maxThumbOffset
109+
} else 0f
110+
111+
val totalRows = (totalItems + columns - 1) / columns
112+
val targetRow = (newProgress * (totalRows - 1)).roundToInt()
113+
val targetIndex = (targetRow * columns).coerceIn(0, totalItems - 1)
114+
115+
coroutineScope.launch {
116+
lazyGridState.scrollToItem(targetIndex)
117+
}
118+
}
119+
)
120+
},
121+
contentAlignment = Alignment.TopCenter,
122+
) {
123+
// Track background
124+
Box(
125+
modifier = Modifier
126+
.fillMaxHeight()
127+
.width(4.dp)
128+
.clip(RoundedCornerShape(2.dp))
129+
.background(
130+
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
131+
)
132+
)
133+
134+
// Calculate thumb offset - use drag offset when dragging, scroll progress otherwise
135+
val maxThumbOffset = (containerHeight - thumbHeight).coerceAtLeast(0f)
136+
val thumbOffset = if (isDragging) {
137+
dragThumbOffset.roundToInt()
138+
} else {
139+
(scrollProgress * maxThumbOffset).roundToInt()
140+
}
141+
142+
// Thumb
143+
Box(
144+
modifier = Modifier
145+
.offset { IntOffset(0, thumbOffset) }
146+
.width(16.dp)
147+
.height(thumbHeightDp)
148+
.clip(RoundedCornerShape(8.dp))
149+
.background(
150+
if (isDragging) {
151+
MaterialTheme.colorScheme.primary
152+
} else {
153+
MaterialTheme.colorScheme.primary.copy(alpha = 0.7f)
154+
}
155+
)
156+
)
157+
}
158+
}
159+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package dev.minios.pdaiv1.presentation.core
2+
3+
import io.reactivex.rxjava3.core.BackpressureStrategy
4+
import io.reactivex.rxjava3.subjects.PublishSubject
5+
6+
/**
7+
* Event bus for synchronizing gallery item states (hidden, liked) between
8+
* GalleryDetailViewModel and GalleryViewModel in real-time.
9+
*/
10+
class GalleryItemStateEvent {
11+
12+
private val hiddenSubject: PublishSubject<HiddenChange> = PublishSubject.create()
13+
private val likedSubject: PublishSubject<LikedChange> = PublishSubject.create()
14+
15+
fun emitHiddenChange(itemId: Long, hidden: Boolean) {
16+
hiddenSubject.onNext(HiddenChange(itemId, hidden))
17+
}
18+
19+
fun emitLikedChange(itemId: Long, liked: Boolean) {
20+
likedSubject.onNext(LikedChange(itemId, liked))
21+
}
22+
23+
fun observeHiddenChanges() = hiddenSubject.toFlowable(BackpressureStrategy.BUFFER)
24+
25+
fun observeLikedChanges() = likedSubject.toFlowable(BackpressureStrategy.BUFFER)
26+
27+
data class HiddenChange(val itemId: Long, val hidden: Boolean)
28+
data class LikedChange(val itemId: Long, val liked: Boolean)
29+
}

presentation/src/main/java/dev/minios/pdaiv1/presentation/core/GenerationMviViewModel.kt

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -250,10 +250,7 @@ abstract class GenerationMviViewModel<S : GenerationMviState, I : GenerationMviI
250250
}
251251

252252
is GenerationMviIntent.Update.Size.AspectRatio -> updateGenerationState {
253-
val baseSize = maxOf(
254-
it.width.toIntOrNull() ?: 512,
255-
it.height.toIntOrNull() ?: 512
256-
)
253+
val baseSize = it.width.toIntOrNull() ?: 512
257254
val (newWidth, newHeight) = intent.ratio.calculateDimensions(baseSize)
258255
it.copyState(
259256
width = newWidth.toString(),

presentation/src/main/java/dev/minios/pdaiv1/presentation/di/UiUtilsModule.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package dev.minios.pdaiv1.presentation.di
22

3+
import dev.minios.pdaiv1.presentation.core.GalleryItemStateEvent
34
import dev.minios.pdaiv1.presentation.core.GenerationFormUpdateEvent
45
import dev.minios.pdaiv1.presentation.screen.debug.DebugMenuAccessor
56
import dev.minios.pdaiv1.presentation.screen.gallery.detail.GalleryDetailBitmapExporter
@@ -15,6 +16,7 @@ internal val uiUtilsModule = module {
1516
factoryOf(::GalleryDetailBitmapExporter)
1617
factoryOf(::GalleryDetailSharing)
1718
singleOf(::GenerationFormUpdateEvent)
19+
singleOf(::GalleryItemStateEvent)
1820
singleOf(::DebugMenuAccessor)
1921
singleOf(::InPaintStateProducer)
2022
}

presentation/src/main/java/dev/minios/pdaiv1/presentation/di/ViewModelModule.kt

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import dev.minios.pdaiv1.presentation.screen.donate.DonateViewModel
1212
import dev.minios.pdaiv1.presentation.screen.drawer.DrawerViewModel
1313
import dev.minios.pdaiv1.presentation.screen.falai.FalAiGenerationViewModel
1414
import dev.minios.pdaiv1.presentation.screen.gallery.detail.GalleryDetailViewModel
15+
import dev.minios.pdaiv1.presentation.screen.gallery.editor.ImageEditorViewModel
1516
import dev.minios.pdaiv1.presentation.screen.gallery.list.GalleryViewModel
1617
import dev.minios.pdaiv1.presentation.screen.home.HomeNavigationViewModel
1718
import dev.minios.pdaiv1.presentation.screen.img2img.ImageToImageViewModel
@@ -42,7 +43,34 @@ val viewModelModule = module {
4243
viewModelOf(::ConfigurationLoaderViewModel)
4344
viewModelOf(::TextToImageViewModel)
4445
viewModelOf(::SettingsViewModel)
45-
viewModelOf(::GalleryViewModel)
46+
viewModel {
47+
GalleryViewModel(
48+
dispatchersProvider = get(),
49+
getMediaStoreInfoUseCase = get(),
50+
backgroundWorkObserver = get(),
51+
preferenceManager = get(),
52+
deleteAllGalleryUseCase = get(),
53+
deleteAllUnlikedUseCase = get(),
54+
deleteGalleryItemsUseCase = get(),
55+
getGenerationResultPagedUseCase = get(),
56+
getGalleryPagedIdsUseCase = get(),
57+
getGalleryItemsUseCase = get(),
58+
getGalleryItemsRawUseCase = get(),
59+
getThumbnailInfoUseCase = get(),
60+
base64ToBitmapConverter = get(),
61+
thumbnailGenerator = get(),
62+
galleryExporter = get(),
63+
schedulersProvider = get(),
64+
mainRouter = get(),
65+
drawerRouter = get(),
66+
mediaStoreGateway = get(),
67+
mediaFileManager = get(),
68+
getAllGalleryUseCase = get(),
69+
galleryItemStateEvent = get(),
70+
likeItemsUseCase = get(),
71+
hideItemsUseCase = get(),
72+
)
73+
}
4674
viewModelOf(::ConnectivityViewModel)
4775
viewModelOf(::InputHistoryViewModel)
4876
viewModelOf(::DebugMenuViewModel)
@@ -99,6 +127,7 @@ val viewModelModule = module {
99127
viewModel { parameters ->
100128
GalleryDetailViewModel(
101129
itemId = parameters.get(),
130+
onNavigateBackCallback = parameters.getOrNull(),
102131
dispatchersProvider = get(),
103132
buildInfoProvider = get(),
104133
preferenceManager = get(),
@@ -107,12 +136,15 @@ val viewModelModule = module {
107136
getGalleryPagedIdsUseCase = get(),
108137
deleteGalleryItemUseCase = get(),
109138
toggleImageVisibilityUseCase = get(),
139+
toggleLikeUseCase = get(),
110140
galleryDetailBitmapExporter = get(),
111141
base64ToBitmapConverter = get(),
112142
schedulersProvider = get(),
113143
generationFormUpdateEvent = get(),
144+
galleryItemStateEvent = get(),
114145
mainRouter = get(),
115146
mediaStoreGateway = get(),
147+
backgroundWorkObserver = get(),
116148
)
117149
}
118150

@@ -129,6 +161,18 @@ val viewModelModule = module {
129161
)
130162
}
131163

164+
viewModel { parameters ->
165+
ImageEditorViewModel(
166+
itemId = parameters.get(),
167+
dispatchersProvider = get(),
168+
getGenerationResultUseCase = get(),
169+
base64ToBitmapConverter = get(),
170+
mediaStoreGateway = get(),
171+
schedulersProvider = get(),
172+
mainRouter = get(),
173+
)
174+
}
175+
132176
viewModel {
133177
ImageToImageViewModel(
134178
dispatchersProvider = get(),

presentation/src/main/java/dev/minios/pdaiv1/presentation/modal/ModalRenderer.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,15 @@ fun ModalRenderer(
239239
onDismissRequest = dismiss,
240240
)
241241

242+
Modal.DeleteUnlikedConfirm -> DecisionInteractiveDialog(
243+
title = LocalizationR.string.interaction_delete_unliked_title.asUiText(),
244+
text = LocalizationR.string.interaction_delete_unliked_sub_title.asUiText(),
245+
confirmActionResId = LocalizationR.string.yes,
246+
dismissActionResId = LocalizationR.string.no,
247+
onConfirmAction = { processIntent(GalleryIntent.Delete.AllUnliked.Confirm) },
248+
onDismissRequest = dismiss,
249+
)
250+
242251
is Modal.ConfirmExport -> DecisionInteractiveDialog(
243252
title = LocalizationR.string.interaction_export_title.asUiText(),
244253
text = if (screenModal.exportAll) {

presentation/src/main/java/dev/minios/pdaiv1/presentation/modal/grid/GridBottomSheet.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,12 @@ fun GridBottomSheet(
3131
Grid.entries.forEach { grid ->
3232
val textCount = stringResource(
3333
id = when (grid) {
34+
Grid.Fixed1 -> LocalizationR.string.one
3435
Grid.Fixed2 -> LocalizationR.string.two
3536
Grid.Fixed3 -> LocalizationR.string.three
3637
Grid.Fixed4 -> LocalizationR.string.four
3738
Grid.Fixed5 -> LocalizationR.string.five
39+
Grid.Fixed6 -> LocalizationR.string.six
3840
},
3941
)
4042
SettingsItem(

presentation/src/main/java/dev/minios/pdaiv1/presentation/model/AspectRatio.kt

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,16 +20,11 @@ enum class AspectRatio(
2020
val normalizedWidth = widthRatio / gcd
2121
val normalizedHeight = heightRatio / gcd
2222

23-
return if (normalizedWidth >= normalizedHeight) {
24-
val width = baseSize
25-
val height = (baseSize * normalizedHeight) / normalizedWidth
26-
// Round to nearest 8 for better compatibility
27-
Pair(roundTo8(width), roundTo8(height))
28-
} else {
29-
val height = baseSize
30-
val width = (baseSize * normalizedWidth) / normalizedHeight
31-
Pair(roundTo8(width), roundTo8(height))
32-
}
23+
// Always use baseSize as width
24+
val width = baseSize
25+
val height = (baseSize * normalizedHeight) / normalizedWidth
26+
// Round to nearest 8 for better compatibility
27+
return Pair(roundTo8(width), roundTo8(height))
3328
}
3429

3530
private fun gcd(a: Int, b: Int): Int = if (b == 0) a else gcd(b, a % b)

0 commit comments

Comments
 (0)