From e3818a05e5d825910740845e1d2567107f5aa32d Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Mon, 13 Apr 2026 05:56:16 +0100 Subject: [PATCH 01/26] Refactor search results and its viewmodel. mostly untested --- .../breadboard/detailview/SearchResults.kt | 126 +++++++++--------- .../viewmodel/SearchResultsViewModel.kt | 126 ++++++++++++------ 2 files changed, 150 insertions(+), 102 deletions(-) diff --git a/app/src/main/java/moe/apex/breadboard/detailview/SearchResults.kt b/app/src/main/java/moe/apex/breadboard/detailview/SearchResults.kt index c2d4c446..72ddd7cb 100644 --- a/app/src/main/java/moe/apex/breadboard/detailview/SearchResults.kt +++ b/app/src/main/java/moe/apex/breadboard/detailview/SearchResults.kt @@ -30,13 +30,13 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController import kotlinx.coroutines.launch import moe.apex.breadboard.image.ImageBoardAuth import moe.apex.breadboard.image.ImageBoardRequirement import moe.apex.breadboard.image.ImageRating -import moe.apex.breadboard.image.AI_TAG_NAMES import moe.apex.breadboard.navigation.Settings import moe.apex.breadboard.preferences.Experiment import moe.apex.breadboard.preferences.ImageSource @@ -82,7 +82,12 @@ fun SearchResults(navController: NavController, source: ImageSource, tagList: Li val manuallyBlockedTags by rememberUpdatedState(prefs.manuallyBlockedTags) val blur = prefs.isExperimentEnabled(Experiment.IMMERSIVE_UI_EFFECTS) - val actuallyBlockedTags = rememberSaveable { mutableStateSetOf() } + val isReady by viewModel.isReady.collectAsStateWithLifecycle() + val viewModelAuth by viewModel.auth.collectAsStateWithLifecycle() + val doneInitialLoad by viewModel.doneInitialLoad.collectAsStateWithLifecycle() + val viewModelImages by viewModel.images.collectAsStateWithLifecycle() + val blockedTags by viewModel.blockedTags.collectAsStateWithLifecycle() + val actuallySelectedRatings = rememberSaveable { mutableStateSetOf().apply { addAll(prefs.ratingsFilter) @@ -90,39 +95,24 @@ fun SearchResults(navController: NavController, source: ImageSource, tagList: Li } fun setUpViewModel(auth: ImageBoardAuth? = null) { - if (!viewModel.isReady) { - viewModel.setup( - imageSource = source, - auth = auth ?: prefs.authFor(source, context), - tags = tagList - ) - } + viewModel.setup( + imageSource = source, + auth = auth ?: prefs.authFor(source, context), + tags = tagList + ) } - /* Populate the internal list of blocked tags. - If the user explicitly searches for an AI tag, - we'll unblock all AI tag variations for this search. */ - fun updateBlockedTags() { - val blockList = if (AI_TAG_NAMES.any { it in tagList }) { - manuallyBlockedTags - } else { - manuallyBlockedTags + AI_TAG_NAMES - } - Snapshot.withMutableSnapshot { - actuallyBlockedTags.clear() - actuallyBlockedTags.addAll(blockList.filter { it !in tagList }) - } - } + fun updateBlockedTags() = viewModel.updateBlockedTags(manuallyBlockedTags, prefs.excludeAi) LaunchedEffect(Unit) { val auth = prefs.authFor(source, context) - if (auth != viewModel.auth) { - viewModel.prepareReset() + if (auth != viewModelAuth) { + viewModel.updateAuth(auth) } - setUpViewModel(auth) - // Don't automatically update on config change like screen rotation if the list is already populated - if (actuallyBlockedTags.isEmpty()) { - updateBlockedTags() + + if (!isReady) { + updateBlockedTags() // Subsequent calls are done in the pull to refresh callback. + setUpViewModel(auth) } } @@ -132,7 +122,7 @@ fun SearchResults(navController: NavController, source: ImageSource, tagList: Li modifier = Modifier .align(Alignment.TopCenter) .then( - if (prefs.filterRatingsLocally) { + if (filterLocally) { Modifier.offset(y = 80.dp) // Height of the ratings box } else Modifier ), @@ -140,9 +130,8 @@ fun SearchResults(navController: NavController, source: ImageSource, tagList: Li ) } ) { - updateBlockedTags() - viewModel.prepareReset() setUpViewModel() + updateBlockedTags() viewModel.loadMore() } @@ -172,9 +161,8 @@ fun SearchResults(navController: NavController, source: ImageSource, tagList: Li ) } } - val imagesToDisplay = viewModel.images.filter { - it.metadata!!.tags.none { tag -> actuallyBlockedTags.contains(tag.lowercase()) } && - if (prefs.filterRatingsLocally) it.metadata.rating in prefs.ratingsFilter else true + val imagesToDisplay = remember(viewModelImages, blockedTags, actuallySelectedRatings.size) { + viewModel.filterImages(if (filterLocally) actuallySelectedRatings else null) } if (showAgeVerificationDialog) { @@ -191,7 +179,7 @@ fun SearchResults(navController: NavController, source: ImageSource, tagList: Li scrollBehavior = scrollBehavior, navController = navController, additionalActions = { - if (viewModel.isReady) { + if (doneInitialLoad) { ScrollToTopArrow( staggeredGridState = viewModel.staggeredGridState, uniformGridState = viewModel.uniformGridState, @@ -204,43 +192,27 @@ fun SearchResults(navController: NavController, source: ImageSource, tagList: Li addBottomPadding = false, blur = isImageCarouselVisible && blur, ) { padding -> - if (!viewModel.isReady) { - return@MainScreenScaffold - } - val needsAuth = remember { source.imageBoard.apiKeyRequirement == ImageBoardRequirement.REQUIRED && prefs.authFor(source, context) == null } if (needsAuth) { - return@MainScreenScaffold Column( + return@MainScreenScaffold ApiKeyRequiredColumn( modifier = Modifier .padding(padding) .padding(top = SMALL_LARGE_SPACER.dp) .fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(MEDIUM_SPACER.dp) + source = source ) { - ExpressiveContainer(position = ListItemPosition.SINGLE_ELEMENT) { - TitleSummary( - title = "API Key required", - summary = "${source.label} requires an API key to search.\n" + - "Add an API key in Settings.\n" + - "Alternatively, use a different image source.", - ) - } - Button( - onClick = { - navController.navigate(Settings) - }, - colors = ButtonDefaults.buttonColors() - ) { - Text("Go to Settings") - } + navController.navigate(Settings) } } + if (!isReady) { + return@MainScreenScaffold + } + ImageGrid( modifier = Modifier .padding(padding.withoutVertical(top = false)) @@ -263,9 +235,9 @@ fun SearchResults(navController: NavController, source: ImageSource, tagList: Li ) } } else null, pullToRefreshController = pullToRefreshController, - doneInitialLoad = viewModel.doneInitialLoad, + doneInitialLoad = doneInitialLoad, onEndReached = viewModel::loadMore, - noImagesContent = { if (viewModel.doneInitialLoad) { NoImages() } } + noImagesContent = { if (doneInitialLoad) { NoImages() } } ) } @@ -276,8 +248,36 @@ fun SearchResults(navController: NavController, source: ImageSource, tagList: Li allImages = imagesToDisplay, onActiveStateChanged = { isImageCarouselVisible = it } ) { oldImage, newImage -> - val index = viewModel.images.indexOf(oldImage) - if (index != -1) viewModel.images[index] = newImage + viewModel.updateImage(oldImage, newImage) } } + + +@Composable +fun ApiKeyRequiredColumn( + modifier: Modifier = Modifier, + source: ImageSource, + onClick: () -> Unit +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MEDIUM_SPACER.dp) + ) { + ExpressiveContainer(position = ListItemPosition.SINGLE_ELEMENT) { + TitleSummary( + title = "API Key required", + summary = "${source.label} requires an API key to search.\n" + + "Add an API key in Settings.\n" + + "Alternatively, use a different image source.", + ) + } + Button( + onClick = onClick, + colors = ButtonDefaults.buttonColors() + ) { + Text("Go to Settings") + } + } +} \ No newline at end of file diff --git a/app/src/main/java/moe/apex/breadboard/viewmodel/SearchResultsViewModel.kt b/app/src/main/java/moe/apex/breadboard/viewmodel/SearchResultsViewModel.kt index fffcc2da..bc137187 100644 --- a/app/src/main/java/moe/apex/breadboard/viewmodel/SearchResultsViewModel.kt +++ b/app/src/main/java/moe/apex/breadboard/viewmodel/SearchResultsViewModel.kt @@ -1,82 +1,130 @@ package moe.apex.breadboard.viewmodel import android.util.Log -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateListOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.compose.runtime.snapshots.Snapshot import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import moe.apex.breadboard.image.Image import moe.apex.breadboard.image.ImageBoardAuth +import moe.apex.breadboard.image.AI_TAG_NAMES +import moe.apex.breadboard.image.ImageRating import moe.apex.breadboard.preferences.ImageSource class SearchResultsViewModel : ViewModel(), GridStateHolder by GridStateHolderDelegate() { - private var isReadyInternal by mutableStateOf(false) - var doneInitialLoad by mutableStateOf(false) - var auth: ImageBoardAuth? = null - val images = mutableStateListOf() - private var shouldKeepSearching by mutableStateOf(true) - private var pageNumber by mutableIntStateOf(0) // We'll set this to the proper value later + private val _isReady = MutableStateFlow(false) + val isReady = _isReady.asStateFlow() + + private val _doneInitialLoad = MutableStateFlow(false) + val doneInitialLoad = _doneInitialLoad.asStateFlow() + + private val _auth = MutableStateFlow(null) + val auth = _auth.asStateFlow() + + private val _images = MutableStateFlow>(emptyList()) + val images = _images.asStateFlow() + + private val _shouldKeepSearching = MutableStateFlow(true) + + private val _pageNumber = MutableStateFlow(0) + + private val _blockedTags = MutableStateFlow>(emptySet()) + val blockedTags = _blockedTags.asStateFlow() + private lateinit var imageSource: ImageSource private lateinit var query: String - var isReady: Boolean = isReadyInternal - private set + private var tagList: List = emptyList() - fun setup(imageSource: ImageSource, auth: ImageBoardAuth?, tags: List) { - if (isReady) { - return - } - val authProvided = auth != null - Log.i("SearchResults", "Setting up SearchResultsViewModel with source: ${imageSource.name}, authenticated: $authProvided, tags: $tags") + fun setup( + imageSource: ImageSource, + auth: ImageBoardAuth?, + tags: List + ) { + Log.i("SearchResults", "Setting up SearchResultsViewModel with source: ${imageSource.name}, tags: $tags") this.imageSource = imageSource - this.auth = auth + tagList = tags query = imageSource.imageBoard.formatTagNameString(tags) - pageNumber = imageSource.imageBoard.firstPageIndex + _auth.value = auth + _pageNumber.value = imageSource.imageBoard.firstPageIndex resetGridStates() - isReady = true + _isReady.value = true } fun prepareReset() { Log.i("SearchResults", "Resetting SearchResultsViewModel") - isReady = false + _isReady.value = false + } + + + fun updateAuth(auth: ImageBoardAuth?) { + _auth.value = auth + } + + + fun updateBlockedTags(manuallyBlockedTags: Set, blockAi: Boolean) { + val blockList = if (AI_TAG_NAMES.any { it in tagList }) { + // Even if blockAi is true, we'll leave them unblocked if the user explicitly searched for AI + manuallyBlockedTags + } else if (blockAi) { + manuallyBlockedTags + AI_TAG_NAMES + } else { + manuallyBlockedTags + } + _blockedTags.value = blockList.filter { it !in tagList }.toSet() } suspend fun loadMore() { - if (!shouldKeepSearching) { + if (!_shouldKeepSearching.value) { Log.i("SearchResults", "No more images to load, stopping search.") return } - if (!isReady) { + if (!_isReady.value) { throw IllegalStateException("SearchResultsViewModel is not ready. Call setup() first.") } + try { - Log.i("SearchResults", "Loading more images for query: $query, page: $pageNumber") - val newImages = imageSource.imageBoard.loadPage(query, pageNumber, auth) + Log.i("SearchResults", "Loading more images for query: $query, page: ${_pageNumber.value}") + val newImages = imageSource.imageBoard.loadPage(query, _pageNumber.value, auth.value) if (newImages.isEmpty()) { - shouldKeepSearching = false + _shouldKeepSearching.value = false } else { - if (pageNumber == imageSource.imageBoard.firstPageIndex) { - Snapshot.withMutableSnapshot { - images.clear() - images.addAll(newImages) - } + if (_pageNumber.value == imageSource.imageBoard.firstPageIndex) { + _images.value = newImages } else { - images += newImages.filter { it !in images } + _images.value += newImages.filter { it !in _images.value } } - pageNumber++ + _pageNumber.value++ } } catch (e: Exception) { Log.e("SearchResults", "Error loading more images", e) - shouldKeepSearching = false + _shouldKeepSearching.value = false + } + if (!_doneInitialLoad.value) { + _doneInitialLoad.value = true } - if (!doneInitialLoad) { - doneInitialLoad = true + } + + + fun updateImage(oldImage: Image, newImage: Image) { + val index = _images.value.indexOf(oldImage) + if (index != -1) { + val updatedImages = _images.value.toMutableList().apply { this[index] = newImage } + _images.value = updatedImages + } + } + + + fun filterImages(ratings: Set? = null): List { + return _images.value.filter { image -> + val isNotBlocked = image.metadata!!.tags.none { tag -> + _blockedTags.value.contains(tag.lowercase()) + } + val passesRatingFilter = ratings?.contains(image.metadata.rating) ?: true + + isNotBlocked && passesRatingFilter } } } \ No newline at end of file From 5bbc8a16682700d5e258b28967aea7a73babb487 Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Mon, 13 Apr 2026 06:15:38 +0100 Subject: [PATCH 02/26] update M3, remove redundant optins --- app/src/main/java/moe/apex/breadboard/MainActivity.kt | 3 --- .../java/moe/apex/breadboard/detailview/ImageGrid.kt | 3 --- .../java/moe/apex/breadboard/detailview/SearchResults.kt | 2 -- .../java/moe/apex/breadboard/favourites/Favourites.kt | 2 -- app/src/main/java/moe/apex/breadboard/home/HomeScreen.kt | 2 -- .../java/moe/apex/breadboard/largeimageview/InfoSheet.kt | 8 +------- .../moe/apex/breadboard/largeimageview/LargeImageView.kt | 5 ++--- .../java/moe/apex/breadboard/preferences/AboutScreen.kt | 3 --- .../moe/apex/breadboard/preferences/BlockedTagsScreen.kt | 2 -- .../apex/breadboard/preferences/ExperimentalScreen.kt | 2 -- .../moe/apex/breadboard/preferences/IgnoredTagsScreen.kt | 2 -- .../moe/apex/breadboard/preferences/LibrariesScreen.kt | 2 -- .../java/moe/apex/breadboard/preferences/Preferences.kt | 2 -- .../preferences/RecommendationsSettingsScreen.kt | 3 +-- .../main/java/moe/apex/breadboard/search/SearchScreen.kt | 2 -- app/src/main/java/moe/apex/breadboard/util/Ui.kt | 9 --------- gradle/libs.versions.toml | 2 +- 17 files changed, 5 insertions(+), 49 deletions(-) diff --git a/app/src/main/java/moe/apex/breadboard/MainActivity.kt b/app/src/main/java/moe/apex/breadboard/MainActivity.kt index aaab13e8..4f70d4a0 100644 --- a/app/src/main/java/moe/apex/breadboard/MainActivity.kt +++ b/app/src/main/java/moe/apex/breadboard/MainActivity.kt @@ -1,5 +1,3 @@ -@file:OptIn(ExperimentalMaterial3Api::class) - package moe.apex.breadboard import android.content.Context @@ -13,7 +11,6 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.ComposeFoundationFlags import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect diff --git a/app/src/main/java/moe/apex/breadboard/detailview/ImageGrid.kt b/app/src/main/java/moe/apex/breadboard/detailview/ImageGrid.kt index c35830d2..8490cc5e 100644 --- a/app/src/main/java/moe/apex/breadboard/detailview/ImageGrid.kt +++ b/app/src/main/java/moe/apex/breadboard/detailview/ImageGrid.kt @@ -31,7 +31,6 @@ import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan import androidx.compose.foundation.lazy.staggeredgrid.itemsIndexed import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -66,7 +65,6 @@ private const val MIN_CELL_WIDTH = 120 private const val MAX_CELL_WIDTH = 144 -@OptIn(ExperimentalMaterial3Api::class) @Composable fun ImageGrid( modifier: Modifier = Modifier, @@ -154,7 +152,6 @@ fun ImageGrid( } -@OptIn(ExperimentalMaterial3Api::class) @Composable private fun StaggeredImageGrid( modifier: Modifier = Modifier, diff --git a/app/src/main/java/moe/apex/breadboard/detailview/SearchResults.kt b/app/src/main/java/moe/apex/breadboard/detailview/SearchResults.kt index 72ddd7cb..81e55192 100644 --- a/app/src/main/java/moe/apex/breadboard/detailview/SearchResults.kt +++ b/app/src/main/java/moe/apex/breadboard/detailview/SearchResults.kt @@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults @@ -63,7 +62,6 @@ import moe.apex.breadboard.util.withoutVertical import moe.apex.breadboard.viewmodel.SearchResultsViewModel -@OptIn(ExperimentalMaterial3Api::class) @Composable fun SearchResults(navController: NavController, source: ImageSource, tagList: List, viewModel: SearchResultsViewModel = viewModel()) { val context = LocalContext.current diff --git a/app/src/main/java/moe/apex/breadboard/favourites/Favourites.kt b/app/src/main/java/moe/apex/breadboard/favourites/Favourites.kt index 60b1e346..fad14caf 100644 --- a/app/src/main/java/moe/apex/breadboard/favourites/Favourites.kt +++ b/app/src/main/java/moe/apex/breadboard/favourites/Favourites.kt @@ -2,7 +2,6 @@ package moe.apex.breadboard.favourites import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults @@ -41,7 +40,6 @@ import moe.apex.breadboard.util.onScroll import moe.apex.breadboard.viewmodel.FavouritesViewModel -@OptIn(ExperimentalMaterial3Api::class) @Composable fun FavouritesPage( navController: NavController, diff --git a/app/src/main/java/moe/apex/breadboard/home/HomeScreen.kt b/app/src/main/java/moe/apex/breadboard/home/HomeScreen.kt index daabf9f8..334292a6 100644 --- a/app/src/main/java/moe/apex/breadboard/home/HomeScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/home/HomeScreen.kt @@ -8,7 +8,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Refresh -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.Text @@ -53,7 +52,6 @@ import moe.apex.breadboard.viewmodel.getGlobalViewModel import kotlin.time.Duration.Companion.days -@OptIn(ExperimentalMaterial3Api::class) @Composable fun HomeScreen( navController: NavController, diff --git a/app/src/main/java/moe/apex/breadboard/largeimageview/InfoSheet.kt b/app/src/main/java/moe/apex/breadboard/largeimageview/InfoSheet.kt index 09130e18..e16efbd8 100644 --- a/app/src/main/java/moe/apex/breadboard/largeimageview/InfoSheet.kt +++ b/app/src/main/java/moe/apex/breadboard/largeimageview/InfoSheet.kt @@ -111,9 +111,7 @@ private enum class InfoSheetPage { } -@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class, - ExperimentalMaterial3ExpressiveApi::class -) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun InfoSheet(navController: NavController, image: Image, onDismissRequest: () -> Unit) { /* I don't really like this whole info/options implementation. @@ -436,7 +434,6 @@ private fun ImageboardDataTabContent( } -@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun UnifiedInfoContent( image: Image, @@ -492,7 +489,6 @@ private fun SplitInfoSheetLazyColumn(content: LazyListScope.() -> Unit) { } -@OptIn(ExperimentalMaterial3ExpressiveApi::class) private fun LazyListScope.infoContentItems( image: Image, onLinkClick: (String) -> Unit, @@ -599,7 +595,6 @@ private fun LazyListScope.infoContentItems( @Suppress("unused") -@OptIn(ExperimentalMaterial3ExpressiveApi::class) private fun LazyListScope.imageboardDataContentItems( image: Image, onLinkClick: (String) -> Unit, // Not currently used but keeping for consistency and possible future use @@ -779,7 +774,6 @@ private fun TagsContainer( } -@OptIn(ExperimentalLayoutApi::class) @Composable private fun ExpandCollapseRow( label: String, diff --git a/app/src/main/java/moe/apex/breadboard/largeimageview/LargeImageView.kt b/app/src/main/java/moe/apex/breadboard/largeimageview/LargeImageView.kt index 5e64b1c7..fe3c225a 100644 --- a/app/src/main/java/moe/apex/breadboard/largeimageview/LargeImageView.kt +++ b/app/src/main/java/moe/apex/breadboard/largeimageview/LargeImageView.kt @@ -788,7 +788,6 @@ fun LargeImage(image: Image) { } -@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable private fun VideoMuteButton( muted: Boolean, @@ -822,7 +821,7 @@ private fun VideoMuteButton( } -@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalSharedTransitionApi::class) +@OptIn(ExperimentalSharedTransitionApi::class) @Composable private fun VideoPlayPauseButton( isPlaying: Boolean, @@ -867,7 +866,7 @@ private fun VideoPlayPauseButton( } -@OptIn(ExperimentalSharedTransitionApi::class, ExperimentalMaterial3ExpressiveApi::class) +@OptIn(ExperimentalSharedTransitionApi::class) @Composable fun LargeVideo(image: Image, isCurrentPage: Boolean, onLongClick: (() -> Unit)? = null) { val context = LocalContext.current diff --git a/app/src/main/java/moe/apex/breadboard/preferences/AboutScreen.kt b/app/src/main/java/moe/apex/breadboard/preferences/AboutScreen.kt index 84cb2204..cc020349 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/AboutScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/AboutScreen.kt @@ -14,8 +14,6 @@ import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialShapes.Companion.Cookie9Sided import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -69,7 +67,6 @@ private val apex2504 = GitHubUser("apex2504", "apex2504") private val devoxin = GitHubUser("devoxin", "devoxin") -@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun AboutScreen(navController: NavHostController) { val context = LocalContext.current diff --git a/app/src/main/java/moe/apex/breadboard/preferences/BlockedTagsScreen.kt b/app/src/main/java/moe/apex/breadboard/preferences/BlockedTagsScreen.kt index 3b5f06f5..4d80fbb1 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/BlockedTagsScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/BlockedTagsScreen.kt @@ -15,7 +15,6 @@ import androidx.compose.material.icons.rounded.Block import androidx.compose.material.icons.rounded.Delete import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -58,7 +57,6 @@ import moe.apex.breadboard.util.TINY_SPACER import moe.apex.breadboard.viewmodel.getGlobalViewModel -@OptIn(ExperimentalMaterial3Api::class) @Composable fun BlockedTagsScreen(navController: NavHostController) { val viewModel = getGlobalViewModel() diff --git a/app/src/main/java/moe/apex/breadboard/preferences/ExperimentalScreen.kt b/app/src/main/java/moe/apex/breadboard/preferences/ExperimentalScreen.kt index 1b6b1d6a..5d83c000 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/ExperimentalScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/ExperimentalScreen.kt @@ -6,7 +6,6 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable @@ -29,7 +28,6 @@ import moe.apex.breadboard.util.TitleSummary import moe.apex.breadboard.util.showToast -@OptIn(ExperimentalMaterial3Api::class) @Composable fun ExperimentalScreen(navController: NavHostController) { val context = LocalContext.current diff --git a/app/src/main/java/moe/apex/breadboard/preferences/IgnoredTagsScreen.kt b/app/src/main/java/moe/apex/breadboard/preferences/IgnoredTagsScreen.kt index 737f9d42..c180bdff 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/IgnoredTagsScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/IgnoredTagsScreen.kt @@ -17,7 +17,6 @@ import androidx.compose.material.icons.rounded.MoreVert import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -56,7 +55,6 @@ import moe.apex.breadboard.util.showToast import moe.apex.breadboard.viewmodel.getGlobalViewModel -@OptIn(ExperimentalMaterial3Api::class) @Composable fun IgnoredTagsScreen(navController: NavHostController) { val viewModel = getGlobalViewModel() diff --git a/app/src/main/java/moe/apex/breadboard/preferences/LibrariesScreen.kt b/app/src/main/java/moe/apex/breadboard/preferences/LibrariesScreen.kt index e270da03..538f98db 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/LibrariesScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/LibrariesScreen.kt @@ -9,7 +9,6 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.AlertDialog -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -38,7 +37,6 @@ import moe.apex.breadboard.util.MEDIUM_SPACER import moe.apex.breadboard.util.TitleSummary -@OptIn(ExperimentalMaterial3Api::class) @Composable fun LibrariesScreen(navController: NavHostController) { val topAppBarState = rememberTopAppBarState() diff --git a/app/src/main/java/moe/apex/breadboard/preferences/Preferences.kt b/app/src/main/java/moe/apex/breadboard/preferences/Preferences.kt index a2e54d8d..91d3531e 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/Preferences.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/Preferences.kt @@ -21,7 +21,6 @@ import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -87,7 +86,6 @@ import org.json.JSONObject import java.io.FileInputStream -@OptIn(ExperimentalMaterial3Api::class) @Composable fun PreferencesScreen(navController: NavHostController) { val topAppBarState = rememberTopAppBarState() diff --git a/app/src/main/java/moe/apex/breadboard/preferences/RecommendationsSettingsScreen.kt b/app/src/main/java/moe/apex/breadboard/preferences/RecommendationsSettingsScreen.kt index 8b319590..579d32af 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/RecommendationsSettingsScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/RecommendationsSettingsScreen.kt @@ -23,7 +23,6 @@ import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.automirrored.rounded.ArrowForward import androidx.compose.material3.ButtonGroup import androidx.compose.material3.ButtonGroupScope -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FilledIconButton import androidx.compose.material3.FilterChip @@ -82,7 +81,7 @@ private const val INFO_SECTION = -2 private const val PAGER_BUTTON_SIZE_DP = 64 -@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun RecommendationsSettingsScreen(navController: NavHostController) { val viewModel = getGlobalViewModel() diff --git a/app/src/main/java/moe/apex/breadboard/search/SearchScreen.kt b/app/src/main/java/moe/apex/breadboard/search/SearchScreen.kt index a37e88ff..df1f66bc 100644 --- a/app/src/main/java/moe/apex/breadboard/search/SearchScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/search/SearchScreen.kt @@ -1,6 +1,5 @@ package moe.apex.breadboard.search -import android.annotation.SuppressLint import android.text.format.DateFormat import android.util.Log import androidx.activity.compose.PredictiveBackHandler @@ -148,7 +147,6 @@ const val ANIMATION_DURATION_MS = 300 @OptIn(ExperimentalMaterial3Api::class) -@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter", "ConfigurationScreenWidthHeight") @Composable fun SearchScreen(navController: NavController, focusRequester: FocusRequester) { /* We use shouldShowSuggestions for determining autocomplete section visibility because if we diff --git a/app/src/main/java/moe/apex/breadboard/util/Ui.kt b/app/src/main/java/moe/apex/breadboard/util/Ui.kt index 30d2cc18..8f9ff664 100644 --- a/app/src/main/java/moe/apex/breadboard/util/Ui.kt +++ b/app/src/main/java/moe/apex/breadboard/util/Ui.kt @@ -221,7 +221,6 @@ private fun NavigationIcon(navController: NavController? = null) { @Composable -@OptIn(ExperimentalMaterial3Api::class) fun LargeTitleBar( title: String, scrollBehavior: TopAppBarScrollBehavior?, @@ -240,7 +239,6 @@ fun LargeTitleBar( } -@OptIn(ExperimentalMaterial3Api::class) @Composable fun SmallTitleBar( title: String, @@ -260,7 +258,6 @@ fun SmallTitleBar( } -@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun FullscreenLoadingSpinner() { Row( @@ -286,7 +283,6 @@ fun PaddingValues.withoutVertical(top: Boolean = true, bottom: Boolean = true) : /** A higher level MainScreenScaffold that provides two preset types of top bars. */ -@OptIn(ExperimentalMaterial3Api::class) @Composable fun MainScreenScaffold( title: String, @@ -322,7 +318,6 @@ fun MainScreenScaffold( fine grained control over its behaviour and appearance. [blur] is not supported on Android 11 or below.*/ -@OptIn(ExperimentalMaterial3Api::class) @Composable fun MainScreenScaffold( topAppBar: @Composable () -> Unit, @@ -1182,7 +1177,6 @@ fun CombinedClickableAction( } -@OptIn(ExperimentalMaterial3Api::class) data class PullToRefreshController( val state: PullToRefreshState, val indicator: @Composable BoxScope.(PullToRefreshController) -> Unit, @@ -1222,7 +1216,6 @@ data class PullToRefreshController( } -@OptIn(ExperimentalMaterial3Api::class) @Composable fun rememberPullToRefreshController( state: PullToRefreshState = rememberPullToRefreshState(), @@ -1245,7 +1238,6 @@ fun rememberPullToRefreshController( object PullToRefreshControllerDefaults { - @OptIn(ExperimentalMaterial3Api::class) @Composable fun Indicator( modifier: Modifier = Modifier, @@ -1268,7 +1260,6 @@ object PullToRefreshControllerDefaults { to work around what I can only assume is a Compose bug whereby the scrolling animation is jumpy when there is a full width item in the grid (like the filter). */ @SuppressLint("FrequentlyChangingValue") -@OptIn(ExperimentalMaterial3Api::class) @Composable fun ScrollToTopArrow( staggeredGridState: LazyStaggeredGridState, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7b73d782..afa17e8e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,7 +8,7 @@ kotlin-bom = "2.2.21" activity-compose = "1.12.1" core-ktx = "1.17.0" compose-bom = "2026.03.01" -material3 = "1.5.0-alpha16" +material3 = "1.5.0-alpha17" documentfile = "1.1.0" navigation-compose = "2.9.6" paging-compose = "3.3.6" From 7a1da91f76d593acd102ae619daa50b0ff6bbf90 Mon Sep 17 00:00:00 2001 From: Flaze <51487578+flazepe@users.noreply.github.com> Date: Sat, 9 May 2026 22:40:02 +0800 Subject: [PATCH 03/26] Merge pull request #54 from flazepe/dev Support pixiv artwork indexes for sharing --- .../java/moe/apex/breadboard/image/Image.kt | 16 ++++++++-- .../moe/apex/breadboard/image/ImageBoard.kt | 7 +---- .../moe/apex/breadboard/preferences/Pref.kt | 4 +-- .../java/moe/apex/breadboard/util/Links.kt | 30 ++++++++++++++++--- .../java/moe/apex/breadboard/util/Pixiv.kt | 29 ++++++++++-------- 5 files changed, 60 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/moe/apex/breadboard/image/Image.kt b/app/src/main/java/moe/apex/breadboard/image/Image.kt index 22d6c77b..4457e45e 100644 --- a/app/src/main/java/moe/apex/breadboard/image/Image.kt +++ b/app/src/main/java/moe/apex/breadboard/image/Image.kt @@ -6,6 +6,7 @@ import moe.apex.breadboard.preferences.ImageSource import moe.apex.breadboard.tag.TagCategory import moe.apex.breadboard.tag.TagGroup import moe.apex.breadboard.util.MigrationOnlyField +import moe.apex.breadboard.util.PixivArtwork @Serializable @@ -21,7 +22,8 @@ data class ImageMetadata( val uncategorisedTags: List? = null, val groupedTags: List = emptyList(), val rating: ImageRating, - val pixivId: Int? = null, + @SerialName("pixivId") + private val pixivArtworkId: Int? = null, @MigrationOnlyField @SerialName("artist") @Deprecated( @@ -36,8 +38,18 @@ data class ImageMetadata( val tags: List get() = groupedTags.fold(emptyList()) { acc, tagGroup -> acc + tagGroup.tags } + val pixivId: Int? + get() = pixivArtworkId ?: pixivArtwork?.id + + val pixivArtwork: PixivArtwork? + get() = PixivArtwork.fromUrl(source) ?: pixivArtworkId?.let { PixivArtwork(it, 0) } + val pixivUrl: String? - get() = pixivId?.let { "https://www.pixiv.net/en/artworks/$it" } + get() = pixivArtwork?.let { + val id = if (it.index == 0) it.id.toString() + else "${it.id}#${it.index}" + "https://www.pixiv.net/artworks/$id" + } } diff --git a/app/src/main/java/moe/apex/breadboard/image/ImageBoard.kt b/app/src/main/java/moe/apex/breadboard/image/ImageBoard.kt index 6439fb78..df524588 100644 --- a/app/src/main/java/moe/apex/breadboard/image/ImageBoard.kt +++ b/app/src/main/java/moe/apex/breadboard/image/ImageBoard.kt @@ -9,7 +9,6 @@ import moe.apex.breadboard.tag.TagCategory import moe.apex.breadboard.tag.TagGroup import moe.apex.breadboard.tag.TagSuggestion import moe.apex.breadboard.util.decodeHtml -import moe.apex.breadboard.util.extractPixivId import org.json.JSONArray import org.json.JSONException import org.json.JSONObject @@ -195,7 +194,6 @@ interface GelbooruBasedImageBoard : ImageBoard { } val metaRating = getRatingFromString(e.getString("rating")) - val metaPixivId = extractPixivId(metaSource) val metadata = ImageMetadata( parentId = metaParentId, hasChildren = null, // Not available for Gelbooru-based image boards @@ -203,7 +201,6 @@ interface GelbooruBasedImageBoard : ImageBoard { source = metaSource, groupedTags = metaGroupedTags, rating = metaRating, - pixivId = metaPixivId, ) return Image(id, fileName, fileFormat, previewUrl, fileUrl, sampleUrl, imageSource, aspectRatio, metadata) @@ -459,7 +456,7 @@ object Danbooru : ImageBoard { source = metaSource, groupedTags = metaGroupedTags, rating = metaRating, - pixivId = metaPixivId, + pixivArtworkId = metaPixivId, ) return Image(id, fileName, fileFormat, previewUrl, fileUrl, sampleUrl, ImageSource.DANBOORU, aspectRatio, metadata) @@ -543,14 +540,12 @@ object Yandere : ImageBoard { TagCategory.GENERAL.group(e.getString("tags").decodeHtml().split(" ")), ) val metaRating = getRatingFromString(e.getString("rating")) - val metaPixivId = extractPixivId(metaSource) val metadata = ImageMetadata( parentId = metaParentId, hasChildren = metaHasChildren, source = metaSource, groupedTags = metaGroupedTags, rating = metaRating, - pixivId = metaPixivId, ) return Image(id, fileName, fileFormat, previewUrl, fileUrl, sampleUrl, ImageSource.YANDERE, aspectRatio, metadata) diff --git a/app/src/main/java/moe/apex/breadboard/preferences/Pref.kt b/app/src/main/java/moe/apex/breadboard/preferences/Pref.kt index c65ca444..a791868d 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/Pref.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/Pref.kt @@ -47,9 +47,9 @@ import moe.apex.breadboard.image.AI_TAG_NAMES import moe.apex.breadboard.tag.TagCategory import moe.apex.breadboard.util.AgeVerification import moe.apex.breadboard.util.MigrationOnlyField +import moe.apex.breadboard.util.PixivArtwork import moe.apex.breadboard.util.SecretsManager import moe.apex.breadboard.util.availableRatingsForSource -import moe.apex.breadboard.util.extractPixivId import moe.apex.breadboard.util.decodeHtml import java.io.IOException @@ -404,7 +404,7 @@ class UserPreferencesRepository(private val dataStore: DataStore) { uncategorisedTags = null, groupedTags = if (!image.metadata.uncategorisedTags.isNullOrEmpty()) listOf(TagCategory.GENERAL.group(image.metadata.tags)) else emptyList(), - pixivId = image.metadata.pixivId ?: extractPixivId(image.metadata.source) + pixivArtworkId = image.metadata.pixivId ?: PixivArtwork.fromUrl(image.metadata.source)?.id ) ) ) diff --git a/app/src/main/java/moe/apex/breadboard/util/Links.kt b/app/src/main/java/moe/apex/breadboard/util/Links.kt index 8c5a6d4e..3222dc2f 100644 --- a/app/src/main/java/moe/apex/breadboard/util/Links.kt +++ b/app/src/main/java/moe/apex/breadboard/util/Links.kt @@ -16,13 +16,35 @@ private val links = mapOf( fun fixLink(link: String): String { val uri = link.toUri() for ((originalHost, fixedHost) in links) { - if (uri.host == originalHost) { - return uri.buildUpon().authority(fixedHost).build().toString() + val newHost = if (uri.host == originalHost) { + fixedHost } else if (uri.host!!.endsWith(".$originalHost")) { val subdomain = uri.host!!.substringBeforeLast(".$originalHost") - val newHost = "$subdomain.$fixedHost" - return uri.buildUpon().authority(newHost).build().toString() + "$subdomain.$fixedHost" + } else { + continue } + + var newPath: String? = null + var newFragment: String? = null + + /* The phixiv fixer cannot take the official pixiv image index syntax into account since + URI fragments are client-sided, so we have to use its own path syntax for indexed images. */ + if (fixedHost == "phixiv.net" && "/artworks/\\d+$".toRegex().containsMatchIn(uri.path ?: "")) { + val index = uri.fragment?.toIntOrNull().takeIf { it != 0 } + if (index != null) { + newPath = "${uri.path}/${index + 1}" + newFragment = "" + } + } + + return uri + .buildUpon() + .authority(newHost) + .path(newPath ?: uri.path) + .fragment(newFragment ?: uri.fragment) + .build() + .toString() } return link } diff --git a/app/src/main/java/moe/apex/breadboard/util/Pixiv.kt b/app/src/main/java/moe/apex/breadboard/util/Pixiv.kt index ee9f6861..163cfbf1 100644 --- a/app/src/main/java/moe/apex/breadboard/util/Pixiv.kt +++ b/app/src/main/java/moe/apex/breadboard/util/Pixiv.kt @@ -4,34 +4,39 @@ package moe.apex.breadboard.util private val PIXIV_CURRENT_RX = // https://i.pximg.net/img-original/img/2022/11/27/21/27/08/103150283_p0.jpg (Safebooru #6517847) // https://i.pximg.net/img-master/img/2019/07/09/08/27/59/75629295_p0_master1200.jpg (Safebooru #3567627) - """https?://i\.pximg\.net/img-(?:original|master)/img/\d+/\d+/\d+/\d+/\d+/\d+/(\d+)_p\d+(_master1200)?\.(png|jpg|jpeg|gif)""".toRegex() + """https?://i\.pximg\.net/img-(original|master)/img/\d+/\d+/\d+/\d+/\d+/\d+/(?\d+)_p(?\d+)(_master1200)?\.(png|jpg|jpeg|gif)""".toRegex() // 2012-2016 pixiv direct image URLs private val PIXIV_2012_TO_2016_RX = listOf( // https://i1.pixiv.net/img-original/img/2016/10/02/16/47/39/59270556_p0.jpg (Safebooru #1843535) // No source, but I'd assume an `img-master` version exists too on this old subdomain - """https?://i\d+\.pixiv\.net/img-(?:original|master)/img/\d+/\d+/\d+/\d+/\d+/\d+/(\d+)_p\d+(_master1200)?\.(png|jpg|jpeg|gif)""".toRegex(), + """https?://i\d+\.pixiv\.net/img-(original|master)/img/\d+/\d+/\d+/\d+/\d+/\d+/(?\d+)_p(?\d+)(_master1200)?\.(png|jpg|jpeg|gif)""".toRegex(), // https://i1.pixiv.net/img47/img/l3lc201/34464791.png (Safebooru #1000441) // https://i1.pixiv.net/img21/img/togainuakira/34478247_big_p8.jpg (Safebooru #1000649) - """https?://i\d+\.pixiv\.net/img\d+/img/.+/(\d+)(_big_p\d+)?\.(png|jpg|jpeg|gif)""".toRegex() + """https?://i\d+\.pixiv\.net/img\d+/img/.+/(?\d+)(_big_p(?\d+))?\.(png|jpg|jpeg|gif)""".toRegex() ) // Pre-2012 pixiv direct image URLs private val PIXIV_PRE_2012_RX = // https://img13.pixiv.net/img/tubasarei/4894590.jpg (Safebooru #166629) - """https?://img\d+\.pixiv\.net/img/.+/(\d+)\.(png|jpg|jpeg|gif)""".toRegex() + """https?://img\d+\.pixiv\.net/img/.+/(?\d+)\.(png|jpg|jpeg|gif)""".toRegex() private val PIXIV_RX = listOf(PIXIV_CURRENT_RX) + PIXIV_2012_TO_2016_RX + PIXIV_PRE_2012_RX -fun extractPixivId(url: String?): Int? { - if (url == null) return null +data class PixivArtwork(val id: Int, val index: Int) { + companion object { + fun fromUrl(url: String?): PixivArtwork? { + if (url == null) return null - for (regex in PIXIV_RX) { - val match = regex.find(url) - val id = match?.groupValues?.get(1)?.toInt()?.takeIf { it != 0 } - if (id != null) return id - } + for (regex in PIXIV_RX) { + val match = regex.find(url) ?: continue + val id = match.groups["id"]?.value?.toIntOrNull().takeIf { it != 0 } ?: continue + val index = match.groups["index"]?.value?.toIntOrNull() ?: 0 + return PixivArtwork(id, index) + } - return null + return null + } + } } From bdab2fa4eb39e2ff3383f042818212c6d9d8fe16 Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Sat, 9 May 2026 16:27:45 +0100 Subject: [PATCH 04/26] Add warning in sheet when a post is AI --- .../breadboard/largeimageview/InfoSheet.kt | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/app/src/main/java/moe/apex/breadboard/largeimageview/InfoSheet.kt b/app/src/main/java/moe/apex/breadboard/largeimageview/InfoSheet.kt index e16efbd8..16167750 100644 --- a/app/src/main/java/moe/apex/breadboard/largeimageview/InfoSheet.kt +++ b/app/src/main/java/moe/apex/breadboard/largeimageview/InfoSheet.kt @@ -32,6 +32,7 @@ import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.rounded.Block import androidx.compose.material.icons.rounded.CheckCircleOutline import androidx.compose.material.icons.rounded.ContentCopy @@ -69,6 +70,7 @@ import androidx.navigation.NavController import kotlinx.coroutines.launch import moe.apex.breadboard.DeepLinkActivity import moe.apex.breadboard.MainActivity +import moe.apex.breadboard.image.AI_TAG_NAMES import moe.apex.breadboard.image.Image import moe.apex.breadboard.navigation.ImageView import moe.apex.breadboard.navigation.Results @@ -500,6 +502,12 @@ private fun LazyListScope.infoContentItems( onTagLongClick: (String) -> Unit, unified: Boolean = false ) { + if (image.isAiGenerated) { + item { + InfoSheetAiWarning() + } + } + item { Row { BasicExpressiveContainer( @@ -810,3 +818,30 @@ private fun createSearchIntent(context: Context, imageSource: ImageSource, queri ) return intent } + + +private val Image.isAiGenerated: Boolean + get() = AI_TAG_NAMES.any { it in this.metadata?.tags.orEmpty() } + + +@Composable +private fun InfoSheetAiWarning() { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = LARGE_SPACER.dp), + horizontalArrangement = Arrangement.spacedBy(MEDIUM_SPACER.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + tint = MaterialTheme.colorScheme.onSurfaceVariant, + imageVector = Icons.Outlined.Info, + contentDescription = null + ) + Text( + text = "This post is AI-generated.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} From 993dd8ab088eee57ee163852e2943cadfef0ad9e Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Sat, 9 May 2026 17:13:00 +0100 Subject: [PATCH 05/26] Begin to make the search screen composable less unwieldy --- .../apex/breadboard/search/SearchScreen.kt | 380 ++++++++++-------- 1 file changed, 207 insertions(+), 173 deletions(-) diff --git a/app/src/main/java/moe/apex/breadboard/search/SearchScreen.kt b/app/src/main/java/moe/apex/breadboard/search/SearchScreen.kt index df1f66bc..cfe74af2 100644 --- a/app/src/main/java/moe/apex/breadboard/search/SearchScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/search/SearchScreen.kt @@ -118,7 +118,6 @@ import moe.apex.breadboard.util.ListItemPosition import moe.apex.breadboard.util.MainScreenScaffold import moe.apex.breadboard.util.BOTTOM_APP_BAR_HEIGHT import moe.apex.breadboard.util.BaseHeading -import moe.apex.breadboard.util.DISABLED_OPACITY import moe.apex.breadboard.util.SearchHistoryListItem import moe.apex.breadboard.util.ExpressiveTagEntryContainer import moe.apex.breadboard.util.LARGE_SPACER @@ -172,9 +171,6 @@ fun SearchScreen(navController: NavController, focusRequester: FocusRequester) { val currentSource = prefs.imageSource var showSearchHistoryPopup by rememberSaveable { mutableStateOf(false) } - val historySheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - val is24h = DateFormat.is24HourFormat(context) - val timeFormat = if (is24h) "HH:mm" else "h:mm a" var searchJob: Job? = null val scope = rememberCoroutineScope() @@ -265,100 +261,6 @@ fun SearchScreen(navController: NavController, focusRequester: FocusRequester) { ) } - - @Composable - fun TagListEntry( - modifier: Modifier = Modifier, - tag: TagSuggestion, - index: Int - ) { - ExpressiveTagEntryContainer( - modifier = modifier, - label = tag.label, - supportingLabel = tag.category, - trailingContent = { - // Show hint that the user can press enter to add the first tag in the list - AnimatedVisibility( - visible = index == 0, - enter = fadeIn(), - exit = fadeOut(tween(durationMillis = 150)) // Long enough to feel smooth, short enough to not linger if it goes from first to non-first when the list updates - ) { - Icon( - imageVector = Icons.AutoMirrored.Rounded.KeyboardReturn, - contentDescription = "Press enter to add", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(end = SMALL_SPACER.dp) - ) - } - }, - position = when (index) { - 0 -> if (mostRecentSuggestions.size == 1) ListItemPosition.SINGLE_ELEMENT else ListItemPosition.TOP - mostRecentSuggestions.lastIndex -> ListItemPosition.BOTTOM - else -> ListItemPosition.MIDDLE - } - ) { - searchString = "" - cleanedSearchString = "" - shouldShowSuggestions = false - addToFilter(tag) - } - } - - - @Composable - fun AutoCompleteTagResults() { - Column( - modifier = Modifier - .consumeWindowInsets(PaddingValues(0.dp, 0.dp, 0.dp, (BOTTOM_APP_BAR_HEIGHT + 16).dp)) - .imePadding() - ) { - Box( - modifier = Modifier - .padding( - start = SMALL_LARGE_SPACER.dp, - end = SMALL_LARGE_SPACER.dp, - bottom = SMALL_LARGE_SPACER.dp - ) - .clip(largerShape) - ) { - val resultsState = rememberLazyListState() - LaunchedEffect(mostRecentSuggestions) { - scope.launch { - resultsState.animateScrollToItem(0) - } - } - LazyColumn( - state = resultsState, - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(2.dp) - ) { - if (mostRecentSuggestions.isEmpty()) { - item { - Text( - fontSize = 16.sp, - text = "No results :(", - textAlign = TextAlign.Center, - modifier = Modifier - .padding(SMALL_LARGE_SPACER.dp) - .fillMaxWidth(), - ) - } - } else { - mostRecentSuggestions.forEachIndexed { index, t -> - item(key = t.label) { - TagListEntry( - modifier = Modifier.animateItem(), - tag = t, - index = index - ) - } - } - } - } - } - } - } - fun performSearch() { if (tagChipList.isEmpty()) return showToast(context, "Please select some tags") @@ -766,7 +668,12 @@ fun SearchScreen(navController: NavController, focusRequester: FocusRequester) { enter = fadeIn(tween(durationMillis = 300)), exit = fadeOut(tween(durationMillis = 300)) ) { - AutoCompleteTagResults() + AutoCompleteTagResults(mostRecentSuggestions) { + searchString = "" + cleanedSearchString = "" + shouldShowSuggestions = false + addToFilter(it) + } } } } @@ -802,83 +709,210 @@ fun SearchScreen(navController: NavController, focusRequester: FocusRequester) { } if (showSearchHistoryPopup) { - val locale = LocalLocale.current.platformLocale - val density = LocalDensity.current - val reversedSearchHistory = remember(prefs.searchHistory) { prefs.searchHistory.reversed() } - var contentHeight by remember { mutableStateOf(Float.MAX_VALUE.dp) } - val containerHeight by animateDpAsState(contentHeight) - val navBarHeight = navBarHeight // We need to calculate this ahead of time - - /* I'd like to use animateContentSize on the LazyColumn but doing so can cause some - strange animation bugs when opening the sheet. - The workaround using a container controlled by onSizeChanged isn't great - but it's close enough to what we want. - - We start with a large initial height and then calculate the correct (smaller) - value because reducing the height doesn't cause the strange opening behaviour - whereas increasing the height apparently does. - - ModalBottomSheet forcibly adds IME padding that causes the LazyColumn to - report a smaller height than desired if the IME is visible when the sheet opens. - This could be useful if we had a text field in the sheet but we don't so the IME - just gets dismissed and the padding becomes an annoyance. - To work around this we'll add the IME height to the LazyColumn's calculated height. - This allows it to animate to the proper height once the IME is finished dismissing. - - ModalBottomSheets are just kind of bad in general. - They're janky to use and the API surface is annoying. */ - - TitledModalBottomSheet( + SearchHistorySheet( + isIncognito = incognito, onDismissRequest = { showSearchHistoryPopup = false }, - sheetState = historySheetState, - title = "Search history" ) { - val imeSize = WindowInsets.ime.asPaddingValues().calculateBottomPadding() // We don't want to calculate this ahead of time + viewModel.setTagSuggestions(it.tags.toList()) + searchString = "" + shouldShowSuggestions = false + scope.launch { + context.prefs.updatePref( + PreferenceKeys.IMAGE_SOURCE, + it.source + ) + context.prefs.replaceImageRatings(it.ratings) + } + } + } +} - Box(modifier = Modifier.height(containerHeight)) { - LazyColumn( - modifier = Modifier - .padding(horizontal = MEDIUM_SPACER.dp) - .clip(largerShape) - .onSizeChanged { - contentHeight = with (density) { it.height.toDp() } + imeSize - }, - verticalArrangement = Arrangement.spacedBy(LARGE_SPACER.dp, Alignment.Top), - contentPadding = PaddingValues(bottom = navBarHeight + MEDIUM_SPACER.dp) - ) { - if (prefs.searchHistory.isEmpty()) { - SearchHistoryStandaloneTextItem("No search history yet. Start searching!") - } else { - if (incognito) { - SearchHistoryStandaloneTextItem("Incognito mode is enabled. Search history will not be saved.") + +@Composable +fun AutoCompleteTagResults( + mostRecentSuggestions: List, + onTagClick: (TagSuggestion) -> Unit +) { + val scope = rememberCoroutineScope() + + Column( + modifier = Modifier + .consumeWindowInsets(PaddingValues(0.dp, 0.dp, 0.dp, (BOTTOM_APP_BAR_HEIGHT + 16).dp)) + .imePadding() + ) { + Box( + modifier = Modifier + .padding( + start = SMALL_LARGE_SPACER.dp, + end = SMALL_LARGE_SPACER.dp, + bottom = SMALL_LARGE_SPACER.dp + ) + .clip(largerShape) + ) { + val resultsState = rememberLazyListState() + + LaunchedEffect(mostRecentSuggestions) { + scope.launch { + resultsState.animateScrollToItem(0) + } + } + + LazyColumn( + state = resultsState, + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + if (mostRecentSuggestions.isEmpty()) { + item { + Text( + fontSize = 16.sp, + text = "No results :(", + textAlign = TextAlign.Center, + modifier = Modifier + .padding(SMALL_LARGE_SPACER.dp) + .fillMaxWidth(), + ) + } + } else { + mostRecentSuggestions.forEachIndexed { index, t -> + item(key = t.label) { + TagListEntry( + modifier = Modifier.animateItem(), + tag = t, + mostRecentSuggestions = mostRecentSuggestions, + index = index, + onClick = onTagClick + ) } - items(reversedSearchHistory, key = { it.timestamp }) { entry -> - val date = Date(entry.timestamp) - val formatter = - SimpleDateFormat("dd MMM $timeFormat", locale) - val formattedDate = formatter.format(date) - - Column( - modifier = Modifier.animateItem(placementSpec = bouncyAnimationSpec()), - verticalArrangement = Arrangement.spacedBy(6.dp) - ) { - BaseHeading( - modifier = Modifier.padding(start = SMALL_SPACER.dp), - text = "$formattedDate \u2022 ${entry.source.label}" - ) - SearchHistoryListItem(entry) { - viewModel.setTagSuggestions(entry.tags.toList()) - searchString = "" - shouldShowSuggestions = false - scope.launch { - context.prefs.updatePref( - PreferenceKeys.IMAGE_SOURCE, - entry.source - ) - context.prefs.replaceImageRatings(entry.ratings) - historySheetState.hide() - showSearchHistoryPopup = false - } + } + } + } + } + } +} + + +@Composable +fun TagListEntry( + modifier: Modifier = Modifier, + tag: TagSuggestion, + mostRecentSuggestions: List, + index: Int, + onClick: (TagSuggestion) -> Unit +) { + ExpressiveTagEntryContainer( + modifier = modifier, + label = tag.label, + supportingLabel = tag.category, + trailingContent = { + // Show hint that the user can press enter to add the first tag in the list + AnimatedVisibility( + visible = index == 0, + enter = fadeIn(), + exit = fadeOut(tween(durationMillis = 150)) // Long enough to feel smooth, short enough to not linger if it goes from first to non-first when the list updates + ) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.KeyboardReturn, + contentDescription = "Press enter to add", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(end = SMALL_SPACER.dp) + ) + } + }, + position = when (index) { + 0 -> if (mostRecentSuggestions.size == 1) ListItemPosition.SINGLE_ELEMENT else ListItemPosition.TOP + mostRecentSuggestions.lastIndex -> ListItemPosition.BOTTOM + else -> ListItemPosition.MIDDLE + }, + onClick = { onClick(tag) } + ) +} + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SearchHistorySheet( + isIncognito: Boolean, + onDismissRequest: () -> Unit, + onSearchHistoryEntryClick: (SearchHistoryEntry) -> Unit +) { + val locale = LocalLocale.current.platformLocale + val context = LocalContext.current + val density = LocalDensity.current + val prefs = LocalPreferences.current + val scope = rememberCoroutineScope() + + val reversedSearchHistory = remember(prefs.searchHistory) { prefs.searchHistory.reversed() } + var contentHeight by remember { mutableStateOf(Float.MAX_VALUE.dp) } + val containerHeight by animateDpAsState(contentHeight) + val navBarHeight = navBarHeight // We need to calculate this ahead of time + + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val is24h = DateFormat.is24HourFormat(context) + val timeFormat = if (is24h) "HH:mm" else "h:mm a" + + /* I'd like to use animateContentSize on the LazyColumn but doing so can cause some + strange animation bugs when opening the sheet. + The workaround using a container controlled by onSizeChanged isn't great + but it's close enough to what we want. + + We start with a large initial height and then calculate the correct (smaller) + value because reducing the height doesn't cause the strange opening behaviour + whereas increasing the height apparently does. + + ModalBottomSheet forcibly adds IME padding that causes the LazyColumn to + report a smaller height than desired if the IME is visible when the sheet opens. + This could be useful if we had a text field in the sheet but we don't so the IME + just gets dismissed and the padding becomes an annoyance. + To work around this we'll add the IME height to the LazyColumn's calculated height. + This allows it to animate to the proper height once the IME is finished dismissing. + + ModalBottomSheets are just kind of bad in general. + They're janky to use and the API surface is annoying. */ + + TitledModalBottomSheet( + onDismissRequest = onDismissRequest, + sheetState = sheetState, + title = "Search history" + ) { + val imeSize = WindowInsets.ime.asPaddingValues().calculateBottomPadding() // We don't want to calculate this ahead of time + + Box(modifier = Modifier.height(containerHeight)) { + LazyColumn( + modifier = Modifier + .padding(horizontal = MEDIUM_SPACER.dp) + .clip(largerShape) + .onSizeChanged { + contentHeight = with (density) { it.height.toDp() } + imeSize + }, + verticalArrangement = Arrangement.spacedBy(LARGE_SPACER.dp, Alignment.Top), + contentPadding = PaddingValues(bottom = navBarHeight + MEDIUM_SPACER.dp) + ) { + if (prefs.searchHistory.isEmpty()) { + SearchHistoryStandaloneTextItem("No search history yet. Start searching!") + } else { + if (isIncognito) { + SearchHistoryStandaloneTextItem("Incognito mode is enabled. Search history will not be saved.") + } + + items(reversedSearchHistory, key = { it.timestamp }) { entry -> + val date = Date(entry.timestamp) + val formatter = + SimpleDateFormat("dd MMM $timeFormat", locale) + val formattedDate = formatter.format(date) + + Column( + modifier = Modifier.animateItem(placementSpec = bouncyAnimationSpec()), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + BaseHeading( + modifier = Modifier.padding(start = SMALL_SPACER.dp), + text = "$formattedDate \u2022 ${entry.source.label}" + ) + SearchHistoryListItem(entry) { + onSearchHistoryEntryClick(entry) + scope.launch { sheetState.hide() }.invokeOnCompletion { + onDismissRequest() } } } @@ -896,11 +930,11 @@ private fun LazyListScope.SearchHistoryStandaloneTextItem(text: String) { Text( text = text, style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, modifier = Modifier .fillMaxWidth() .padding(SMALL_LARGE_SPACER.dp) - .alpha(DISABLED_OPACITY) ) } } From 1b808816d2f37e2d44b35a6dda92f4ff8b6141c0 Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Sun, 17 May 2026 14:48:36 +0100 Subject: [PATCH 06/26] Space in recommendations query instead of +, fixes #56 --- app/src/main/java/moe/apex/breadboard/util/Recommendations.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/moe/apex/breadboard/util/Recommendations.kt b/app/src/main/java/moe/apex/breadboard/util/Recommendations.kt index 75946bfa..d3aad1b4 100644 --- a/app/src/main/java/moe/apex/breadboard/util/Recommendations.kt +++ b/app/src/main/java/moe/apex/breadboard/util/Recommendations.kt @@ -138,7 +138,7 @@ class RecommendationsProvider( ) imageSource.imageBoard.formatTagNameString(recommendedTags) } else { - "${imageSource.imageBoard.formatTagNameString(recommendedTags)}+${ + "${imageSource.imageBoard.formatTagNameString(recommendedTags)} ${ ImageRating.buildSearchStringFor( if (showAllRatings) { ImageRating.entries.filter { it != ImageRating.UNKNOWN } From df9ff2b71623820d0e595a141434734be2ccff56 Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Sun, 17 May 2026 14:48:52 +0100 Subject: [PATCH 07/26] I guess that's needed after all --- app/src/main/java/moe/apex/breadboard/home/HomeScreen.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/java/moe/apex/breadboard/home/HomeScreen.kt b/app/src/main/java/moe/apex/breadboard/home/HomeScreen.kt index 334292a6..daabf9f8 100644 --- a/app/src/main/java/moe/apex/breadboard/home/HomeScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/home/HomeScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.Text @@ -52,6 +53,7 @@ import moe.apex.breadboard.viewmodel.getGlobalViewModel import kotlin.time.Duration.Companion.days +@OptIn(ExperimentalMaterial3Api::class) @Composable fun HomeScreen( navController: NavController, From 4dfd54c945492fcf253ea41e4d6947bedb5966b9 Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Fri, 12 Jun 2026 18:38:11 +0100 Subject: [PATCH 08/26] gelbooru preview URLs need the referer header now too --- .../main/java/moe/apex/breadboard/detailview/ImageGrid.kt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/src/main/java/moe/apex/breadboard/detailview/ImageGrid.kt b/app/src/main/java/moe/apex/breadboard/detailview/ImageGrid.kt index 8490cc5e..dec28e38 100644 --- a/app/src/main/java/moe/apex/breadboard/detailview/ImageGrid.kt +++ b/app/src/main/java/moe/apex/breadboard/detailview/ImageGrid.kt @@ -48,6 +48,8 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage +import coil3.network.NetworkHeaders +import coil3.network.httpHeaders import coil3.request.ImageRequest import coil3.request.crossfade import kotlinx.coroutines.launch @@ -310,8 +312,14 @@ private fun ImagePreview( onImageClick: (Int, Image) -> Unit ) { val context = LocalContext.current + + val headersBuilder = remember { + NetworkHeaders.Builder() + .set("Referer", image.imageSource.imageBoard.baseUrl) + } val model = remember { ImageRequest.Builder(context) .data(image.previewUrl) + .httpHeaders(headersBuilder.build()) .crossfade(true) .build() } From 7afb3ffcb4ee450843fd5b0950aefa681ab561dd Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Sat, 13 Jun 2026 03:19:39 +0100 Subject: [PATCH 09/26] Overhaul recommendations, ensure picked tags are good matches --- .../moe/apex/breadboard/home/HomeScreen.kt | 3 +- .../moe/apex/breadboard/preferences/Pref.kt | 18 ++- .../RecommendationsSettingsScreen.kt | 22 +-- .../apex/breadboard/util/Recommendations.kt | 51 ++----- .../breadboard/util/RecommendationsHelper.kt | 133 ++++++++++++++++++ .../util/RecommendationsHelperTest.kt | 66 +++++++++ 6 files changed, 224 insertions(+), 69 deletions(-) create mode 100644 app/src/test/java/moe/apex/breadboard/util/RecommendationsHelperTest.kt diff --git a/app/src/main/java/moe/apex/breadboard/home/HomeScreen.kt b/app/src/main/java/moe/apex/breadboard/home/HomeScreen.kt index daabf9f8..a31f0e4c 100644 --- a/app/src/main/java/moe/apex/breadboard/home/HomeScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/home/HomeScreen.kt @@ -139,8 +139,7 @@ fun HomeScreen( initialBlockedTags = prefs.blockedTags, initialUnfollowedTags = prefs.unfollowedTags + builtInIgnoredTags, selectionSize = prefs.recommendationsTagCount, - poolSize = prefs.recommendationsPoolSize, - useWeightedSelection = prefs.recommendationsWeightedSelection + poolSize = prefs.recommendationsPoolSize ) newProvider.prepareRecommendedTags() viewModel.setRecommendationsProvider(newProvider) diff --git a/app/src/main/java/moe/apex/breadboard/preferences/Pref.kt b/app/src/main/java/moe/apex/breadboard/preferences/Pref.kt index a791868d..bd1d6136 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/Pref.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/Pref.kt @@ -92,7 +92,6 @@ data object PrefNames { const val UNFOLLOWED_TAGS = "unfollowed_tags" const val RECOMMENDATIONS_TAG_COUNT = "recommendations_tag_count" const val RECOMMENDATIONS_POOL_SIZE = "recommendations_pool_size" - const val RECOMMENDATIONS_WEIGHTED_SELECTION = "recommendations_weighted_selection" const val INTERNAL_IGNORE_LIST_TIMESTAMP = "internal_ignore_list_timestamp" const val INTERNAL_IGNORE_LIST = "internal_ignore_list" const val AUTOPLAY_VIDEOS = "autoplay_videos" @@ -126,7 +125,6 @@ object PreferenceKeys { val UNFOLLOWED_TAGS = stringSetPreferencesKey(PrefNames.UNFOLLOWED_TAGS) val RECOMMENDATIONS_TAG_COUNT = intPreferencesKey(PrefNames.RECOMMENDATIONS_TAG_COUNT) val RECOMMENDATIONS_POOL_SIZE = intPreferencesKey(PrefNames.RECOMMENDATIONS_POOL_SIZE) - val RECOMMENDATIONS_WEIGHTED_SELECTION = booleanPreferencesKey(PrefNames.RECOMMENDATIONS_WEIGHTED_SELECTION) val INTERNAL_IGNORE_LIST_TIMESTAMP = longPreferencesKey(PrefNames.INTERNAL_IGNORE_LIST_TIMESTAMP) val INTERNAL_IGNORE_LIST = stringSetPreferencesKey(PrefNames.INTERNAL_IGNORE_LIST) val AUTOPLAY_VIDEOS = stringPreferencesKey(PrefNames.AUTOPLAY_VIDEOS) @@ -224,7 +222,6 @@ data class Prefs( val unfollowedTags: Set, val recommendationsTagCount: Int, val recommendationsPoolSize: Int, - val recommendationsWeightedSelection: Boolean, val internalIgnoreListTimestamp: Long, val internalIgnoreList: Set, val autoplayVideos: AutoplayVideosMode, @@ -257,7 +254,6 @@ data class Prefs( unfollowedTags = emptySet(), recommendationsTagCount = 3, recommendationsPoolSize = 7, - recommendationsWeightedSelection = true, internalIgnoreListTimestamp = 0, internalIgnoreList = emptySet(), autoplayVideos = AutoplayVideosMode.OFF, @@ -322,7 +318,6 @@ class UserPreferencesRepository(private val dataStore: DataStore) { PreferenceKeys.UNFOLLOWED_TAGS to PrefMeta(PrefCategory.SETTING, mergeable = true), PreferenceKeys.RECOMMENDATIONS_TAG_COUNT to PrefMeta(PrefCategory.SETTING), PreferenceKeys.RECOMMENDATIONS_POOL_SIZE to PrefMeta(PrefCategory.SETTING), - PreferenceKeys.RECOMMENDATIONS_WEIGHTED_SELECTION to PrefMeta(PrefCategory.SETTING), PreferenceKeys.INTERNAL_IGNORE_LIST_TIMESTAMP to PrefMeta(PrefCategory.SETTING, exportable = false), PreferenceKeys.INTERNAL_IGNORE_LIST to PrefMeta(PrefCategory.SETTING, exportable = false), PreferenceKeys.UNIFIED_INFO_SHEET to PrefMeta(PrefCategory.SETTING) @@ -548,6 +543,17 @@ class UserPreferencesRepository(private val dataStore: DataStore) { } } + /* Version 3.2.2 removes the weighted tags pref in favour of a new recommendation system. */ + if (lastUsedVersionCode < 322) { + val data = dataStore.data.first() + val key = booleanPreferencesKey("recommendations_weighted_selection") + if (data.contains(key)) { + dataStore.edit { prefs -> + prefs.remove(key) + } + } + } + /* Always clear the internal ignored list on updates. */ if (BuildConfig.VERSION_CODE != lastUsedVersionCode) { dataStore.edit { prefs -> @@ -803,7 +809,6 @@ class UserPreferencesRepository(private val dataStore: DataStore) { val unfollowedTags = preferences[PreferenceKeys.UNFOLLOWED_TAGS] ?: Prefs.DEFAULT.unfollowedTags val recommendationsTagCount = preferences[PreferenceKeys.RECOMMENDATIONS_TAG_COUNT] ?: Prefs.DEFAULT.recommendationsTagCount val recommendationsPoolSize = preferences[PreferenceKeys.RECOMMENDATIONS_POOL_SIZE] ?: Prefs.DEFAULT.recommendationsPoolSize - val recommendationsWeightedSelection = preferences[PreferenceKeys.RECOMMENDATIONS_WEIGHTED_SELECTION] ?: Prefs.DEFAULT.recommendationsWeightedSelection val internalIgnoreListTimestamp = preferences[PreferenceKeys.INTERNAL_IGNORE_LIST_TIMESTAMP] ?: Prefs.DEFAULT.internalIgnoreListTimestamp val internalIgnoreList = preferences[PreferenceKeys.INTERNAL_IGNORE_LIST] ?: Prefs.DEFAULT.internalIgnoreList val autoplayVideos = preferences[PreferenceKeys.AUTOPLAY_VIDEOS]?.let { AutoplayVideosMode.valueOf(it) } ?: Prefs.DEFAULT.autoplayVideos @@ -835,7 +840,6 @@ class UserPreferencesRepository(private val dataStore: DataStore) { unfollowedTags, recommendationsTagCount, recommendationsPoolSize, - recommendationsWeightedSelection, internalIgnoreListTimestamp, internalIgnoreList, autoplayVideos, diff --git a/app/src/main/java/moe/apex/breadboard/preferences/RecommendationsSettingsScreen.kt b/app/src/main/java/moe/apex/breadboard/preferences/RecommendationsSettingsScreen.kt index 579d32af..49bbda47 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/RecommendationsSettingsScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/RecommendationsSettingsScreen.kt @@ -361,9 +361,9 @@ fun RecommendationsSettingsScreen(navController: NavHostController) { Summary( modifier = Modifier.padding(horizontal = MEDIUM_LARGE_SPACER.dp), text = "Your frequent tags consist of the most common tags from your " + - "favourite images. Breadboard will use these tags to recommend " + - "new content. You can tap a tag above to ignore it, preventing it " + - "from being used to recommend new content." + "favourite images. Breadboard will intelligently use these tags to " + + "recommend new content. You can tap a tag above to ignore it, " + + "preventing it from being used to recommend new content." ) } @@ -438,22 +438,6 @@ fun RecommendationsSettingsScreen(navController: NavHostController) { } } } - item { - SwitchPref( - checked = prefs.recommendationsWeightedSelection, - title = "Respect tag order", - summary = "Tags that appear earlier in your frequent tags list are " + - "more likely to be used when recommending new content." - ) { - scope.launch { - userPreferencesRepository.updatePref( - PreferenceKeys.RECOMMENDATIONS_WEIGHTED_SELECTION, - it - ) - } - resetRecommendations() - } - } } } diff --git a/app/src/main/java/moe/apex/breadboard/util/Recommendations.kt b/app/src/main/java/moe/apex/breadboard/util/Recommendations.kt index d3aad1b4..a2168df8 100644 --- a/app/src/main/java/moe/apex/breadboard/util/Recommendations.kt +++ b/app/src/main/java/moe/apex/breadboard/util/Recommendations.kt @@ -15,7 +15,6 @@ import moe.apex.breadboard.image.ImageRating import moe.apex.breadboard.preferences.ImageSource import moe.apex.breadboard.viewmodel.GridStateHolderDelegate import moe.apex.breadboard.viewmodel.GridStateHolder -import kotlin.random.Random class RecommendationsProvider( @@ -28,7 +27,6 @@ class RecommendationsProvider( private val initialUnfollowedTags: Set, private val selectionSize: Int, private val poolSize: Int, - private val useWeightedSelection: Boolean, ) : GridStateHolder by GridStateHolderDelegate() { companion object { private const val SELECTION_SIZE_DANBOORU = 2 @@ -68,52 +66,23 @@ class RecommendationsProvider( shouldKeepSearching = true pageNumber = imageSource.imageBoard.firstPageIndex - val tagsFromFavourites = RecommendationsHelper.getAllTags( - images = seedImages.filter { it.imageSource == imageSource }, - allowAllRatings = showAllRatings, - excludedTags = blockedTags - ) + val filteredSeedImages = seedImages.filter { it.imageSource == imageSource } + .filter { showAllRatings || it.metadata?.rating == ImageRating.SAFE } - if (tagsFromFavourites.isEmpty()) { + if (filteredSeedImages.isEmpty()) { return } - val topTags = RecommendationsHelper.getMostCommonTags( - allTags = tagsFromFavourites, - followedTagsLimit = poolSize, + val finalSelectionSize = if (imageSource == ImageSource.DANBOORU && auth == null) SELECTION_SIZE_DANBOORU else selectionSize + + val selected = RecommendationsHelper.getRecommendedTags( + images = filteredSeedImages, + selectionSize = finalSelectionSize, + poolSize = poolSize, + hiddenTags = blockedTags, unfollowedTags = unfollowedTags ) - val finalSelectionSize = if (imageSource == ImageSource.DANBOORU && auth == null) SELECTION_SIZE_DANBOORU else selectionSize - val selected = if (topTags.size <= finalSelectionSize) { - topTags.map { it.first } - } else if (useWeightedSelection) { - val weightedTags = topTags.toMutableList() - var totalWeight = weightedTags.sumOf { it.second } - val result = mutableSetOf() - - while (result.size < finalSelectionSize && weightedTags.isNotEmpty() && totalWeight > 0) { - var randomNumber = Random.nextInt(totalWeight) - - var chosenTag: Pair? = null - for (tag in weightedTags) { - if (randomNumber < tag.second) { - chosenTag = tag - break - } - randomNumber -= tag.second - } - - chosenTag?.let { - result.add(it.first) - totalWeight -= it.second - weightedTags.remove(it) - } - } - result.toList() - } else { - topTags.map { it.first }.shuffled().take(finalSelectionSize) - } recommendedTags.addAll(selected) } diff --git a/app/src/main/java/moe/apex/breadboard/util/RecommendationsHelper.kt b/app/src/main/java/moe/apex/breadboard/util/RecommendationsHelper.kt index 25bd36ab..87cf5026 100644 --- a/app/src/main/java/moe/apex/breadboard/util/RecommendationsHelper.kt +++ b/app/src/main/java/moe/apex/breadboard/util/RecommendationsHelper.kt @@ -2,6 +2,8 @@ package moe.apex.breadboard.util import moe.apex.breadboard.image.Image import moe.apex.breadboard.image.ImageRating +import moe.apex.breadboard.tag.TagCategory +import kotlin.random.Random.Default.nextInt import kotlin.text.lowercase @@ -68,4 +70,135 @@ object RecommendationsHelper { return wantedTags } } + + /** + * Recommends a set of tags based on the user's favorites, ensuring that the selected + * tags co-occur in the user's favorites to provide relevant results. + */ + fun getRecommendedTags( + images: List, + selectionSize: Int, + poolSize: Int, + hiddenTags: Set = emptySet(), + unfollowedTags: Set = emptySet(), + ): List { + if (images.isEmpty()) return emptyList() + + /* Get all tags and their frequencies. + Ideally we'd just be able to use groupedTags, but older favourites don't have these, + and some sources don't have support for grouped tags at all (yande.re) in Breadboard. */ + val allTags = images.flatMap { it.metadata?.tags ?: emptyList() } + .map { it.lowercase() } + .filter { it !in hiddenTags && it !in unfollowedTags } + + if (allTags.isEmpty()) return emptyList() + + val tagFrequencies = allTags.groupingBy { it }.eachCount() + + /* Get the most specific category for each tag. + Older favourites might not have categorised tags (therefore default to GENERAL), + so if the same tag exists in more than one image and the categories are different, + prefer the more specific one. */ + val tagCategories = mutableMapOf() + images.forEach { image -> + image.metadata?.groupedTags?.forEach { group -> + group.tags.forEach { tag -> + val lowerTag = tag.lowercase() + val currentCategory = tagCategories[lowerTag] + // If we find a non-GENERAL category, use that instead. + if (currentCategory == null || currentCategory == TagCategory.GENERAL) { + tagCategories[lowerTag] = group.category + } + } + } + } + + // These are the tags that could actually be chosen + val pool = tagFrequencies.entries + .sortedByDescending { it.value } + .take(poolSize) + .map { it.key } + + if (pool.isEmpty()) return emptyList() + + val result = mutableListOf() + val remainingPool = pool.toMutableList() + + /* Of the tags in the pool, choose one to use as the primary. + The other chosen tags will be based on this one. */ + val primaryTag = pickWeightedTag(remainingPool, tagFrequencies, tagCategories) ?: return emptyList() + result.add(primaryTag) + remainingPool.remove(primaryTag) + + // Pick other tags that have appeared together with the primary one. + while (result.size < selectionSize && remainingPool.isNotEmpty()) { + val coExistenceCounts = mutableMapOf() + + // Count how many times each tag in the remaining pool co-exists with the other chosen tags. + remainingPool.forEach { candidate -> + var count = 0 + images.forEach { image -> + val imageTags = image.metadata?.tags?.map { it.lowercase() } ?: emptyList() + if (imageTags.contains(candidate) && imageTags.containsAll(result)) { + count++ + } + } + if (count > 0) { + coExistenceCounts[candidate] = count + } + } + + if (coExistenceCounts.isEmpty()) break + + val nextTag = pickWeightedTag(coExistenceCounts.keys.toList(), coExistenceCounts, tagCategories) + if (nextTag != null) { + result.add(nextTag) + remainingPool.remove(nextTag) + } else { + break + } + } + + return result + } + + + private fun pickWeightedTag( + candidates: List, + frequencies: Map, + categories: Map + ): String? { + if (candidates.isEmpty()) { + return null + } + + val weights = candidates.map { tag -> + val freq = frequencies[tag] ?: 1 + val category = categories[tag] ?: TagCategory.GENERAL + val categoryWeight = when (category) { + // TODO: Consider making these configurable in future? + TagCategory.ARTIST -> 1.5 + TagCategory.CHARACTER -> 2.0 + TagCategory.COPYRIGHT -> 3.5 + TagCategory.META -> 0.0 // We'll just ignore meta tags entirely for now. + TagCategory.GENERAL -> 1.0 + } + (freq * categoryWeight * 10).toInt() + } + + val totalWeight = weights.sum() + if (totalWeight == 0) { // This seems exceptionally unlikely but not impossible. + return candidates.random() + } + + var random = nextInt(totalWeight) + for (i in candidates.indices) { + random -= weights[i] + if (random < 0) { + return candidates[i] + } + } + + return candidates.last() + } } \ No newline at end of file diff --git a/app/src/test/java/moe/apex/breadboard/util/RecommendationsHelperTest.kt b/app/src/test/java/moe/apex/breadboard/util/RecommendationsHelperTest.kt new file mode 100644 index 00000000..c358f942 --- /dev/null +++ b/app/src/test/java/moe/apex/breadboard/util/RecommendationsHelperTest.kt @@ -0,0 +1,66 @@ +package moe.apex.breadboard.util + +import moe.apex.breadboard.image.Image +import moe.apex.breadboard.image.ImageMetadata +import moe.apex.breadboard.image.ImageRating +import moe.apex.breadboard.tag.TagCategory +import moe.apex.breadboard.tag.TagGroup +import org.junit.Assert.assertTrue +import org.junit.Test + +class RecommendationsHelperTest { + private fun createImage(tags: List, category: TagCategory = TagCategory.GENERAL): Image { + return Image( + fileName = "test", + fileFormat = "jpg", + previewUrl = "", + fileUrl = "", + sampleUrl = "", + metadata = ImageMetadata( + rating = ImageRating.SAFE, + groupedTags = listOf(TagGroup(category, tags)) + ) + ) + } + + + @Test + fun testCoOccurrenceSelection() { + // Two clusters of unrelated tags + // Cluster 1: blue_archive, hoshino_(blue_archive), halo + // Cluster 2: arknights, silverash_(arknights), sword + val images = listOf( + createImage(listOf("blue_archive", "hoshino_(blue_archive)", "halo")), + createImage(listOf("blue_archive", "hoshino_(blue_archive)", "halo")), + createImage(listOf("blue_archive", "hoshino_(blue_archive)", "halo")), + createImage(listOf("arknights", "silverash_(arknights)", "sword")), + createImage(listOf("arknights", "silverash_(arknights)", "sword")), + createImage(listOf("arknights", "silverash_(arknights)", "sword")) + ) + + val recommended = RecommendationsHelper.getRecommendedTags( + images = images, + selectionSize = 2, + poolSize = 10 + ) + + // Resultant tags should all be either BA-related or Arknights-related, but not mixed. + val cluster1 = setOf("blue_archive", "hoshino_(blue_archive)", "halo") + val cluster2 = setOf("arknights", "silverash_(arknights)", "sword") + + assertTrue("Recommended tags: $recommended", + recommended.all { it in cluster1 } || recommended.all { it in cluster2 } + ) + } + + + @Test + fun testEmptyImages() { + val recommended = RecommendationsHelper.getRecommendedTags( + images = emptyList(), + selectionSize = 2, + poolSize = 10 + ) + assertTrue(recommended.isEmpty()) + } +} From b49fbc8fc38c9a80d6c9a43bfc5d714b43672a6b Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Sat, 13 Jun 2026 03:20:18 +0100 Subject: [PATCH 10/26] Update version --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index e029853c..32189985 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -20,8 +20,8 @@ android { applicationId "moe.apex.breadboard" minSdk 26 targetSdk 36 - versionCode 321 - versionName "3.2.1" + versionCode 322 + versionName "3.2.2" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From d1b56bb6e1c0b970f7c13cd2bfcd4c74003fdda3 Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Sat, 13 Jun 2026 03:42:11 +0100 Subject: [PATCH 11/26] Update deps and apparently the optins are needed again lol? --- app/build.gradle | 2 +- .../breadboard/detailview/SearchResults.kt | 2 ++ .../apex/breadboard/favourites/Favourites.kt | 2 ++ .../largeimageview/LargeImageView.kt | 2 +- .../breadboard/preferences/AboutScreen.kt | 3 ++ .../preferences/BlockedTagsScreen.kt | 2 ++ .../preferences/ExperimentalScreen.kt | 2 ++ .../preferences/IgnoredTagsScreen.kt | 2 ++ .../breadboard/preferences/LibrariesScreen.kt | 2 ++ .../breadboard/preferences/Preferences.kt | 2 ++ .../RecommendationsSettingsScreen.kt | 3 +- .../main/java/moe/apex/breadboard/util/Ui.kt | 4 +++ gradle.properties | 12 ++++++- gradle/libs.versions.toml | 32 +++++++++---------- gradle/wrapper/gradle-wrapper.properties | 2 +- 15 files changed, 53 insertions(+), 21 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 32189985..1e841c8f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -14,7 +14,7 @@ if (rootProject.file("local.properties").exists()) { android { namespace 'moe.apex.breadboard' - compileSdk 36 + compileSdk 37 defaultConfig { applicationId "moe.apex.breadboard" diff --git a/app/src/main/java/moe/apex/breadboard/detailview/SearchResults.kt b/app/src/main/java/moe/apex/breadboard/detailview/SearchResults.kt index 81e55192..72ddd7cb 100644 --- a/app/src/main/java/moe/apex/breadboard/detailview/SearchResults.kt +++ b/app/src/main/java/moe/apex/breadboard/detailview/SearchResults.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults @@ -62,6 +63,7 @@ import moe.apex.breadboard.util.withoutVertical import moe.apex.breadboard.viewmodel.SearchResultsViewModel +@OptIn(ExperimentalMaterial3Api::class) @Composable fun SearchResults(navController: NavController, source: ImageSource, tagList: List, viewModel: SearchResultsViewModel = viewModel()) { val context = LocalContext.current diff --git a/app/src/main/java/moe/apex/breadboard/favourites/Favourites.kt b/app/src/main/java/moe/apex/breadboard/favourites/Favourites.kt index fad14caf..60b1e346 100644 --- a/app/src/main/java/moe/apex/breadboard/favourites/Favourites.kt +++ b/app/src/main/java/moe/apex/breadboard/favourites/Favourites.kt @@ -2,6 +2,7 @@ package moe.apex.breadboard.favourites import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults @@ -40,6 +41,7 @@ import moe.apex.breadboard.util.onScroll import moe.apex.breadboard.viewmodel.FavouritesViewModel +@OptIn(ExperimentalMaterial3Api::class) @Composable fun FavouritesPage( navController: NavController, diff --git a/app/src/main/java/moe/apex/breadboard/largeimageview/LargeImageView.kt b/app/src/main/java/moe/apex/breadboard/largeimageview/LargeImageView.kt index fe3c225a..dca9de23 100644 --- a/app/src/main/java/moe/apex/breadboard/largeimageview/LargeImageView.kt +++ b/app/src/main/java/moe/apex/breadboard/largeimageview/LargeImageView.kt @@ -821,7 +821,7 @@ private fun VideoMuteButton( } -@OptIn(ExperimentalSharedTransitionApi::class) +@OptIn(ExperimentalSharedTransitionApi::class, ExperimentalMaterial3ExpressiveApi::class) @Composable private fun VideoPlayPauseButton( isPlaying: Boolean, diff --git a/app/src/main/java/moe/apex/breadboard/preferences/AboutScreen.kt b/app/src/main/java/moe/apex/breadboard/preferences/AboutScreen.kt index cc020349..84cb2204 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/AboutScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/AboutScreen.kt @@ -14,6 +14,8 @@ import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialShapes.Companion.Cookie9Sided import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -67,6 +69,7 @@ private val apex2504 = GitHubUser("apex2504", "apex2504") private val devoxin = GitHubUser("devoxin", "devoxin") +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun AboutScreen(navController: NavHostController) { val context = LocalContext.current diff --git a/app/src/main/java/moe/apex/breadboard/preferences/BlockedTagsScreen.kt b/app/src/main/java/moe/apex/breadboard/preferences/BlockedTagsScreen.kt index 4d80fbb1..3b5f06f5 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/BlockedTagsScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/BlockedTagsScreen.kt @@ -15,6 +15,7 @@ import androidx.compose.material.icons.rounded.Block import androidx.compose.material.icons.rounded.Delete import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -57,6 +58,7 @@ import moe.apex.breadboard.util.TINY_SPACER import moe.apex.breadboard.viewmodel.getGlobalViewModel +@OptIn(ExperimentalMaterial3Api::class) @Composable fun BlockedTagsScreen(navController: NavHostController) { val viewModel = getGlobalViewModel() diff --git a/app/src/main/java/moe/apex/breadboard/preferences/ExperimentalScreen.kt b/app/src/main/java/moe/apex/breadboard/preferences/ExperimentalScreen.kt index 5d83c000..1b6b1d6a 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/ExperimentalScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/ExperimentalScreen.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable @@ -28,6 +29,7 @@ import moe.apex.breadboard.util.TitleSummary import moe.apex.breadboard.util.showToast +@OptIn(ExperimentalMaterial3Api::class) @Composable fun ExperimentalScreen(navController: NavHostController) { val context = LocalContext.current diff --git a/app/src/main/java/moe/apex/breadboard/preferences/IgnoredTagsScreen.kt b/app/src/main/java/moe/apex/breadboard/preferences/IgnoredTagsScreen.kt index c180bdff..737f9d42 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/IgnoredTagsScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/IgnoredTagsScreen.kt @@ -17,6 +17,7 @@ import androidx.compose.material.icons.rounded.MoreVert import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -55,6 +56,7 @@ import moe.apex.breadboard.util.showToast import moe.apex.breadboard.viewmodel.getGlobalViewModel +@OptIn(ExperimentalMaterial3Api::class) @Composable fun IgnoredTagsScreen(navController: NavHostController) { val viewModel = getGlobalViewModel() diff --git a/app/src/main/java/moe/apex/breadboard/preferences/LibrariesScreen.kt b/app/src/main/java/moe/apex/breadboard/preferences/LibrariesScreen.kt index 538f98db..e270da03 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/LibrariesScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/LibrariesScreen.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -37,6 +38,7 @@ import moe.apex.breadboard.util.MEDIUM_SPACER import moe.apex.breadboard.util.TitleSummary +@OptIn(ExperimentalMaterial3Api::class) @Composable fun LibrariesScreen(navController: NavHostController) { val topAppBarState = rememberTopAppBarState() diff --git a/app/src/main/java/moe/apex/breadboard/preferences/Preferences.kt b/app/src/main/java/moe/apex/breadboard/preferences/Preferences.kt index 91d3531e..a2e54d8d 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/Preferences.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/Preferences.kt @@ -21,6 +21,7 @@ import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -86,6 +87,7 @@ import org.json.JSONObject import java.io.FileInputStream +@OptIn(ExperimentalMaterial3Api::class) @Composable fun PreferencesScreen(navController: NavHostController) { val topAppBarState = rememberTopAppBarState() diff --git a/app/src/main/java/moe/apex/breadboard/preferences/RecommendationsSettingsScreen.kt b/app/src/main/java/moe/apex/breadboard/preferences/RecommendationsSettingsScreen.kt index 49bbda47..602b6169 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/RecommendationsSettingsScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/RecommendationsSettingsScreen.kt @@ -23,6 +23,7 @@ import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.automirrored.rounded.ArrowForward import androidx.compose.material3.ButtonGroup import androidx.compose.material3.ButtonGroupScope +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FilledIconButton import androidx.compose.material3.FilterChip @@ -81,7 +82,7 @@ private const val INFO_SECTION = -2 private const val PAGER_BUTTON_SIZE_DP = 64 -@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable fun RecommendationsSettingsScreen(navController: NavHostController) { val viewModel = getGlobalViewModel() diff --git a/app/src/main/java/moe/apex/breadboard/util/Ui.kt b/app/src/main/java/moe/apex/breadboard/util/Ui.kt index 8f9ff664..18fde99d 100644 --- a/app/src/main/java/moe/apex/breadboard/util/Ui.kt +++ b/app/src/main/java/moe/apex/breadboard/util/Ui.kt @@ -220,6 +220,7 @@ private fun NavigationIcon(navController: NavController? = null) { } +@OptIn(ExperimentalMaterial3Api::class) @Composable fun LargeTitleBar( title: String, @@ -239,6 +240,7 @@ fun LargeTitleBar( } +@OptIn(ExperimentalMaterial3Api::class) @Composable fun SmallTitleBar( title: String, @@ -258,6 +260,7 @@ fun SmallTitleBar( } +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun FullscreenLoadingSpinner() { Row( @@ -283,6 +286,7 @@ fun PaddingValues.withoutVertical(top: Boolean = true, bottom: Boolean = true) : /** A higher level MainScreenScaffold that provides two preset types of top bars. */ +@OptIn(ExperimentalMaterial3Api::class) @Composable fun MainScreenScaffold( title: String, diff --git a/gradle.properties b/gradle.properties index 3c5031eb..a626f610 100644 --- a/gradle.properties +++ b/gradle.properties @@ -20,4 +20,14 @@ kotlin.code.style=official # Enables namespacing of each library's R class so that its R class includes only the # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library -android.nonTransitiveRClass=true \ No newline at end of file +android.nonTransitiveRClass=true +android.defaults.buildfeatures.resvalues=true +android.sdk.defaultTargetSdkToCompileSdkIfUnset=false +android.enableAppCompileTimeRClass=false +android.usesSdkInManifest.disallowed=false +android.uniquePackageNames=false +android.dependency.useConstraints=true +android.r8.strictFullModeForKeepRules=false +android.r8.optimizedResourceShrinking=false +android.builtInKotlin=false +android.newDsl=false \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index afa17e8e..d33d6af9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,27 +1,27 @@ [versions] -android-gradle-plugin = "8.11.1" -kotlin = "2.2.21" +android-gradle-plugin = "9.2.1" +kotlin = "2.4.0" aboutlibraries = "13.1.0" compose-compiler = "1.5.18" -okhttp = "5.3.2" -kotlin-bom = "2.2.21" -activity-compose = "1.12.1" -core-ktx = "1.17.0" -compose-bom = "2026.03.01" -material3 = "1.5.0-alpha17" +okhttp = "5.4.0" +kotlin-bom = "2.4.0" +activity-compose = "1.13.0" +core-ktx = "1.19.0" +compose-bom = "2026.05.01" +material3 = "1.5.0-alpha21" documentfile = "1.1.0" -navigation-compose = "2.9.6" -paging-compose = "3.3.6" -datastore-preferences = "1.2.0" -coil = "3.3.0" -telephoto = "0.18.0" -kotlinx-serialization = "1.9.0" -reorderable = "3.0.0" +navigation-compose = "2.9.8" +paging-compose = "3.5.0" +datastore-preferences = "1.2.1" +coil = "3.5.0" +telephoto = "0.19.0" +kotlinx-serialization = "1.11.0" +reorderable = "3.1.0" graphics-shapes = "1.1.0" junit = "4.13.2" test-ext-junit = "1.3.0" espresso-core = "3.7.0" -composemediaplayer = "0.8.7" +composemediaplayer = "0.10.0" [libraries] okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 0cf0a7d9..fff93f6b 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Fri Jul 21 03:45:27 BST 2023 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists From 9b160fbc244c8886dedcb02c3f38bd1c568b0e06 Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Mon, 15 Jun 2026 21:55:12 +0100 Subject: [PATCH 12/26] move selected ratings to search result view model --- .../breadboard/detailview/SearchResults.kt | 30 ++++++++----------- .../viewmodel/SearchResultsViewModel.kt | 18 +++++++++++ 2 files changed, 30 insertions(+), 18 deletions(-) diff --git a/app/src/main/java/moe/apex/breadboard/detailview/SearchResults.kt b/app/src/main/java/moe/apex/breadboard/detailview/SearchResults.kt index 72ddd7cb..be71cc07 100644 --- a/app/src/main/java/moe/apex/breadboard/detailview/SearchResults.kt +++ b/app/src/main/java/moe/apex/breadboard/detailview/SearchResults.kt @@ -18,11 +18,9 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.mutableStateSetOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.Snapshot import androidx.compose.ui.Alignment @@ -87,12 +85,7 @@ fun SearchResults(navController: NavController, source: ImageSource, tagList: Li val doneInitialLoad by viewModel.doneInitialLoad.collectAsStateWithLifecycle() val viewModelImages by viewModel.images.collectAsStateWithLifecycle() val blockedTags by viewModel.blockedTags.collectAsStateWithLifecycle() - - val actuallySelectedRatings = rememberSaveable { - mutableStateSetOf().apply { - addAll(prefs.ratingsFilter) - } - } + val selectedRatings by viewModel.selectedRatings.collectAsStateWithLifecycle() fun setUpViewModel(auth: ImageBoardAuth? = null) { viewModel.setup( @@ -112,6 +105,7 @@ fun SearchResults(navController: NavController, source: ImageSource, tagList: Li if (!isReady) { updateBlockedTags() // Subsequent calls are done in the pull to refresh callback. + viewModel.updateSelectedRatings(prefs.ratingsFilter) setUpViewModel(auth) } } @@ -135,34 +129,34 @@ fun SearchResults(navController: NavController, source: ImageSource, tagList: Li viewModel.loadMore() } - val ratingRows: List<@Composable () -> Unit> = availableRatingsForCurrentSource.map { { + val ratingRows: List<@Composable () -> Unit> = availableRatingsForCurrentSource.map { rating -> { FilterChip( - selected = it in actuallySelectedRatings, - label = { Text(it.label) }, + selected = rating in selectedRatings, + label = { Text(rating.label) }, colors = filterChipSolidColor, border = null, onClick = { - if (it in actuallySelectedRatings) { - actuallySelectedRatings.remove(it) + if (rating in selectedRatings) { + viewModel.removeRating(rating) } else { - if (it != ImageRating.SAFE && !AgeVerification.hasVerifiedAge(prefs)) { + if (rating != ImageRating.SAFE && !AgeVerification.hasVerifiedAge(prefs)) { showAgeVerificationDialog = true return@FilterChip } else { - actuallySelectedRatings.add(it) + viewModel.addRating(rating) } } scope.launch { preferencesRepository.updateSet( PreferenceKeys.RATINGS_FILTER, - actuallySelectedRatings.map { it.name }) + viewModel.selectedRatings.value.map { it.name }) } } ) } } - val imagesToDisplay = remember(viewModelImages, blockedTags, actuallySelectedRatings.size) { - viewModel.filterImages(if (filterLocally) actuallySelectedRatings else null) + val imagesToDisplay = remember(viewModelImages, blockedTags, selectedRatings) { + viewModel.filterImages(if (filterLocally) selectedRatings else null) } if (showAgeVerificationDialog) { diff --git a/app/src/main/java/moe/apex/breadboard/viewmodel/SearchResultsViewModel.kt b/app/src/main/java/moe/apex/breadboard/viewmodel/SearchResultsViewModel.kt index bc137187..d24ea2e4 100644 --- a/app/src/main/java/moe/apex/breadboard/viewmodel/SearchResultsViewModel.kt +++ b/app/src/main/java/moe/apex/breadboard/viewmodel/SearchResultsViewModel.kt @@ -31,6 +31,9 @@ class SearchResultsViewModel : ViewModel(), GridStateHolder by GridStateHolderDe private val _blockedTags = MutableStateFlow>(emptySet()) val blockedTags = _blockedTags.asStateFlow() + private val _selectedRatings = MutableStateFlow>(emptySet()) + val selectedRatings = _selectedRatings.asStateFlow() + private lateinit var imageSource: ImageSource private lateinit var query: String private var tagList: List = emptyList() @@ -117,6 +120,21 @@ class SearchResultsViewModel : ViewModel(), GridStateHolder by GridStateHolderDe } + fun addRating(rating: ImageRating) { + _selectedRatings.value += rating + } + + + fun removeRating(rating: ImageRating) { + _selectedRatings.value -= rating + } + + + fun updateSelectedRatings(ratings: Set) { + _selectedRatings.value = ratings + } + + fun filterImages(ratings: Set? = null): List { return _images.value.filter { image -> val isNotBlocked = image.metadata!!.tags.none { tag -> From 837157c0e0bc63305c5f9cbd4a1b58db9cba3f7c Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Mon, 15 Jun 2026 22:01:10 +0100 Subject: [PATCH 13/26] rename initialPage --- .../moe/apex/breadboard/detailview/SearchResults.kt | 6 +++--- .../java/moe/apex/breadboard/favourites/Favourites.kt | 6 +++--- .../main/java/moe/apex/breadboard/home/HomeScreen.kt | 6 +++--- .../apex/breadboard/largeimageview/LargeImageView.kt | 10 +++++----- 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/moe/apex/breadboard/detailview/SearchResults.kt b/app/src/main/java/moe/apex/breadboard/detailview/SearchResults.kt index be71cc07..fada6c05 100644 --- a/app/src/main/java/moe/apex/breadboard/detailview/SearchResults.kt +++ b/app/src/main/java/moe/apex/breadboard/detailview/SearchResults.kt @@ -72,7 +72,7 @@ fun SearchResults(navController: NavController, source: ImageSource, tagList: Li val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(topAppBarState) var isImageCarouselVisible by remember { mutableStateOf(false) } - var initialPage by remember { mutableIntStateOf(0) } + var selectedImageIndex by remember { mutableIntStateOf(0) } var showAgeVerificationDialog by remember { mutableStateOf(false) } val preferencesRepository = LocalContext.current.prefs @@ -216,7 +216,7 @@ fun SearchResults(navController: NavController, source: ImageSource, tagList: Li images = imagesToDisplay, onImageClick = { index, _ -> Snapshot.withMutableSnapshot { - initialPage = index + selectedImageIndex = index isImageCarouselVisible = true } }, @@ -238,7 +238,7 @@ fun SearchResults(navController: NavController, source: ImageSource, tagList: Li OffsetBasedLargeImageView( navController = navController, isActive = isImageCarouselVisible, - initialPage = initialPage, + initialSelectedImageIndex = selectedImageIndex, allImages = imagesToDisplay, onActiveStateChanged = { isImageCarouselVisible = it } ) { oldImage, newImage -> diff --git a/app/src/main/java/moe/apex/breadboard/favourites/Favourites.kt b/app/src/main/java/moe/apex/breadboard/favourites/Favourites.kt index 60b1e346..18e3f2b5 100644 --- a/app/src/main/java/moe/apex/breadboard/favourites/Favourites.kt +++ b/app/src/main/java/moe/apex/breadboard/favourites/Favourites.kt @@ -53,7 +53,7 @@ fun FavouritesPage( val topAppBarState = rememberTopAppBarState() val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(topAppBarState) var isImageCarouselVisible by remember { mutableStateOf(false) } - var initialPage by remember { mutableIntStateOf(0) } + var selectedImageIndex by remember { mutableIntStateOf(0) } val scope = rememberCoroutineScope() val blur = prefs.isExperimentEnabled(Experiment.IMMERSIVE_UI_EFFECTS) @@ -147,7 +147,7 @@ fun FavouritesPage( images = images, onImageClick = { index, _ -> Snapshot.withMutableSnapshot { - initialPage = index + selectedImageIndex = index isImageCarouselVisible = true } }, @@ -165,7 +165,7 @@ fun FavouritesPage( OffsetBasedLargeImageView( navController = navController, isActive = isImageCarouselVisible, - initialPage = initialPage, + initialSelectedImageIndex = selectedImageIndex, allImages = images, onActiveStateChanged = { isImageCarouselVisible = it diff --git a/app/src/main/java/moe/apex/breadboard/home/HomeScreen.kt b/app/src/main/java/moe/apex/breadboard/home/HomeScreen.kt index a31f0e4c..40723283 100644 --- a/app/src/main/java/moe/apex/breadboard/home/HomeScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/home/HomeScreen.kt @@ -70,7 +70,7 @@ fun HomeScreen( val topAppBarState = rememberTopAppBarState() val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(topAppBarState) var shouldShowLargeImage by remember { mutableStateOf(false) } - var initialPage by remember { mutableIntStateOf(0) } + var selectedImageIndex by remember { mutableIntStateOf(0) } val blur = prefs.isExperimentEnabled(Experiment.IMMERSIVE_UI_EFFECTS) @@ -183,7 +183,7 @@ fun HomeScreen( }, onImageClick = { index, _ -> Snapshot.withMutableSnapshot { - initialPage = index + selectedImageIndex = index shouldShowLargeImage = true } }, @@ -205,7 +205,7 @@ fun HomeScreen( OffsetBasedLargeImageView( navController = navController, isActive = shouldShowLargeImage, - initialPage = initialPage, + initialSelectedImageIndex = selectedImageIndex, allImages = recommendedImages ?: emptyList(), onActiveStateChanged = { shouldShowLargeImage = it diff --git a/app/src/main/java/moe/apex/breadboard/largeimageview/LargeImageView.kt b/app/src/main/java/moe/apex/breadboard/largeimageview/LargeImageView.kt index dca9de23..6d918b34 100644 --- a/app/src/main/java/moe/apex/breadboard/largeimageview/LargeImageView.kt +++ b/app/src/main/java/moe/apex/breadboard/largeimageview/LargeImageView.kt @@ -197,15 +197,15 @@ private enum class ToolbarState { @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") @Composable -fun LargeImageView( +private fun LargeImageView( navController: NavController, - initialPage: Int, + initialSelectedImageIndex: Int, allImages: List, onImageUpdate: (suspend (Image, Image) -> Unit)? = null, onZoomedStatusChanged: ((Boolean) -> Unit)? = null ) { val pagerState = rememberPagerState( - initialPage = initialPage, + initialPage = initialSelectedImageIndex, initialPageOffsetFraction = 0f ) { allImages.size } var canChangePage by remember { mutableStateOf(false) } @@ -1185,7 +1185,7 @@ fun LargeVideo(image: Image, isCurrentPage: Boolean, onLongClick: (() -> Unit)? fun OffsetBasedLargeImageView( navController: NavController, isActive: Boolean, - initialPage: Int, + initialSelectedImageIndex: Int, allImages: List, onActiveStateChanged: (Boolean) -> Unit = { }, onImageUpdate: (suspend (Image, Image) -> Unit)? = null, @@ -1311,7 +1311,7 @@ fun OffsetBasedLargeImageView( key(viewerSessionId) { LargeImageView( navController = navController, - initialPage = initialPage, + initialSelectedImageIndex = initialSelectedImageIndex, allImages = allImages, onImageUpdate = onImageUpdate, onZoomedStatusChanged = { canDragDown = !it } From eff6127742a8d177359ae42edf6a8024957a9445 Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Mon, 15 Jun 2026 23:37:44 +0100 Subject: [PATCH 14/26] Switch out deprecated modal bottom sheet state --- .../moe/apex/breadboard/DeepLinkActivity.kt | 6 ++ .../java/moe/apex/breadboard/MainActivity.kt | 6 +- .../breadboard/largeimageview/InfoSheet.kt | 7 +- .../breadboard/preferences/PreferencesUI.kt | 8 +- .../apex/breadboard/search/SearchScreen.kt | 99 +++++++------------ .../main/java/moe/apex/breadboard/util/Ui.kt | 12 ++- 6 files changed, 70 insertions(+), 68 deletions(-) diff --git a/app/src/main/java/moe/apex/breadboard/DeepLinkActivity.kt b/app/src/main/java/moe/apex/breadboard/DeepLinkActivity.kt index e18fb404..457ee2ce 100644 --- a/app/src/main/java/moe/apex/breadboard/DeepLinkActivity.kt +++ b/app/src/main/java/moe/apex/breadboard/DeepLinkActivity.kt @@ -10,6 +10,8 @@ import android.view.KeyEvent import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.compose.material3.ComposeMaterial3Flags +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.collectAsState @@ -58,10 +60,14 @@ class DeepLinkActivity : SingletonImageLoader.Factory, ComponentActivity(), Volu } + @OptIn(ExperimentalMaterial3Api::class) override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) + // https://issuetracker.google.com/issues/521534697 TODO + ComposeMaterial3Flags.isBottomSheetPartiallyExpandedDeterministicEnabled = false + applicationContext.preferencesDataStoreFile("preferences") runBlocking { prefs.handleMigration(applicationContext) } val initialPrefs = runBlocking { prefs.getPreferences.first() } diff --git a/app/src/main/java/moe/apex/breadboard/MainActivity.kt b/app/src/main/java/moe/apex/breadboard/MainActivity.kt index 4f70d4a0..db5ef6f6 100644 --- a/app/src/main/java/moe/apex/breadboard/MainActivity.kt +++ b/app/src/main/java/moe/apex/breadboard/MainActivity.kt @@ -11,6 +11,8 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.ComposeFoundationFlags import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.material3.ComposeMaterial3Flags +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -77,12 +79,14 @@ class MainActivity : SingletonImageLoader.Factory, ComponentActivity(), VolumeBu } - @OptIn(ExperimentalFoundationApi::class) + @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) // Having this enabled seems to result in some items becoming invisible when animating lazy lists ComposeFoundationFlags.isSkipItemPlacementAnimationFixEnabled = false + // https://issuetracker.google.com/issues/521534697 TODO + ComposeMaterial3Flags.isBottomSheetPartiallyExpandedDeterministicEnabled = false applicationContext.preferencesDataStoreFile("preferences") runBlocking { prefs.handleMigration(applicationContext) } diff --git a/app/src/main/java/moe/apex/breadboard/largeimageview/InfoSheet.kt b/app/src/main/java/moe/apex/breadboard/largeimageview/InfoSheet.kt index 16167750..bcd0511d 100644 --- a/app/src/main/java/moe/apex/breadboard/largeimageview/InfoSheet.kt +++ b/app/src/main/java/moe/apex/breadboard/largeimageview/InfoSheet.kt @@ -49,7 +49,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.SheetValue import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.rememberBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -130,7 +130,10 @@ fun InfoSheet(navController: NavController, image: Image, onDismissRequest: () - val scope = rememberCoroutineScope() val unified = prefs.unifiedInfoSheet - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = !unified) + val sheetState = rememberBottomSheetState( + initialValue = SheetValue.Hidden, + enabledValues = if (unified) SheetValue.entries.toSet() else setOf(SheetValue.Expanded, SheetValue.Hidden), + ) var sheetPage by remember { mutableStateOf(InfoSheetPage.SOURCES) } fun hideAndThen(block: () -> Unit = { }) { diff --git a/app/src/main/java/moe/apex/breadboard/preferences/PreferencesUI.kt b/app/src/main/java/moe/apex/breadboard/preferences/PreferencesUI.kt index 102b0526..60a47292 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/PreferencesUI.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/PreferencesUI.kt @@ -36,13 +36,14 @@ import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.RadioButton +import androidx.compose.material3.SheetValue import androidx.compose.material3.Slider import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.rememberBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -104,7 +105,10 @@ fun InfoButton( text: String, ) { var showInfoSheet by rememberSaveable { mutableStateOf(false) } - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val sheetState = rememberBottomSheetState( + initialValue = SheetValue.Hidden, + enabledValues = setOf(SheetValue.Hidden, SheetValue.Expanded), + ) if (showInfoSheet) { TitledModalBottomSheet( onDismissRequest = { showInfoSheet = false }, diff --git a/app/src/main/java/moe/apex/breadboard/search/SearchScreen.kt b/app/src/main/java/moe/apex/breadboard/search/SearchScreen.kt index cfe74af2..dedc55ce 100644 --- a/app/src/main/java/moe/apex/breadboard/search/SearchScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/search/SearchScreen.kt @@ -5,6 +5,7 @@ import android.util.Log import androidx.activity.compose.PredictiveBackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.animateContentSize import androidx.compose.animation.core.EaseOutBack import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.animateFloatAsState @@ -58,12 +59,13 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SheetValue import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TextField import androidx.compose.material3.TextFieldDefaults import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.rememberBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -838,82 +840,57 @@ private fun SearchHistorySheet( ) { val locale = LocalLocale.current.platformLocale val context = LocalContext.current - val density = LocalDensity.current val prefs = LocalPreferences.current val scope = rememberCoroutineScope() val reversedSearchHistory = remember(prefs.searchHistory) { prefs.searchHistory.reversed() } - var contentHeight by remember { mutableStateOf(Float.MAX_VALUE.dp) } - val containerHeight by animateDpAsState(contentHeight) val navBarHeight = navBarHeight // We need to calculate this ahead of time - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val sheetState = rememberBottomSheetState( + initialValue = SheetValue.Hidden, + enabledValues = setOf(SheetValue.Hidden, SheetValue.Expanded) + ) val is24h = DateFormat.is24HourFormat(context) val timeFormat = if (is24h) "HH:mm" else "h:mm a" - /* I'd like to use animateContentSize on the LazyColumn but doing so can cause some - strange animation bugs when opening the sheet. - The workaround using a container controlled by onSizeChanged isn't great - but it's close enough to what we want. - - We start with a large initial height and then calculate the correct (smaller) - value because reducing the height doesn't cause the strange opening behaviour - whereas increasing the height apparently does. - - ModalBottomSheet forcibly adds IME padding that causes the LazyColumn to - report a smaller height than desired if the IME is visible when the sheet opens. - This could be useful if we had a text field in the sheet but we don't so the IME - just gets dismissed and the padding becomes an annoyance. - To work around this we'll add the IME height to the LazyColumn's calculated height. - This allows it to animate to the proper height once the IME is finished dismissing. - - ModalBottomSheets are just kind of bad in general. - They're janky to use and the API surface is annoying. */ - TitledModalBottomSheet( onDismissRequest = onDismissRequest, sheetState = sheetState, title = "Search history" ) { - val imeSize = WindowInsets.ime.asPaddingValues().calculateBottomPadding() // We don't want to calculate this ahead of time + LazyColumn( + modifier = Modifier + .padding(horizontal = MEDIUM_SPACER.dp) + .clip(largerShape) + .animateContentSize(), + verticalArrangement = Arrangement.spacedBy(LARGE_SPACER.dp, Alignment.Top), + contentPadding = PaddingValues(bottom = navBarHeight + MEDIUM_SPACER.dp) + ) { + if (prefs.searchHistory.isEmpty()) { + SearchHistoryStandaloneTextItem("No search history yet. Start searching!") + } else { + if (isIncognito) { + SearchHistoryStandaloneTextItem("Incognito mode is enabled. Search history will not be saved.") + } - Box(modifier = Modifier.height(containerHeight)) { - LazyColumn( - modifier = Modifier - .padding(horizontal = MEDIUM_SPACER.dp) - .clip(largerShape) - .onSizeChanged { - contentHeight = with (density) { it.height.toDp() } + imeSize - }, - verticalArrangement = Arrangement.spacedBy(LARGE_SPACER.dp, Alignment.Top), - contentPadding = PaddingValues(bottom = navBarHeight + MEDIUM_SPACER.dp) - ) { - if (prefs.searchHistory.isEmpty()) { - SearchHistoryStandaloneTextItem("No search history yet. Start searching!") - } else { - if (isIncognito) { - SearchHistoryStandaloneTextItem("Incognito mode is enabled. Search history will not be saved.") - } + items(reversedSearchHistory, key = { it.timestamp }) { entry -> + val date = Date(entry.timestamp) + val formatter = + SimpleDateFormat("dd MMM $timeFormat", locale) + val formattedDate = formatter.format(date) - items(reversedSearchHistory, key = { it.timestamp }) { entry -> - val date = Date(entry.timestamp) - val formatter = - SimpleDateFormat("dd MMM $timeFormat", locale) - val formattedDate = formatter.format(date) - - Column( - modifier = Modifier.animateItem(placementSpec = bouncyAnimationSpec()), - verticalArrangement = Arrangement.spacedBy(6.dp) - ) { - BaseHeading( - modifier = Modifier.padding(start = SMALL_SPACER.dp), - text = "$formattedDate \u2022 ${entry.source.label}" - ) - SearchHistoryListItem(entry) { - onSearchHistoryEntryClick(entry) - scope.launch { sheetState.hide() }.invokeOnCompletion { - onDismissRequest() - } + Column( + modifier = Modifier.animateItem(placementSpec = bouncyAnimationSpec()), + verticalArrangement = Arrangement.spacedBy(6.dp) + ) { + BaseHeading( + modifier = Modifier.padding(start = SMALL_SPACER.dp), + text = "$formattedDate \u2022 ${entry.source.label}" + ) + SearchHistoryListItem(entry) { + onSearchHistoryEntryClick(entry) + scope.launch { sheetState.hide() }.invokeOnCompletion { + onDismissRequest() } } } diff --git a/app/src/main/java/moe/apex/breadboard/util/Ui.kt b/app/src/main/java/moe/apex/breadboard/util/Ui.kt index 18fde99d..314c8833 100644 --- a/app/src/main/java/moe/apex/breadboard/util/Ui.kt +++ b/app/src/main/java/moe/apex/breadboard/util/Ui.kt @@ -83,6 +83,7 @@ import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Scaffold import androidx.compose.material3.SelectableChipColors import androidx.compose.material3.SheetState +import androidx.compose.material3.SheetValue import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar @@ -92,7 +93,7 @@ import androidx.compose.material3.VerticalDivider import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults import androidx.compose.material3.pulltorefresh.PullToRefreshState import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState -import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.rememberBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf @@ -809,11 +810,18 @@ fun SearchHistoryListItem( fun TitledModalBottomSheet( onDismissRequest: () -> Unit, modifier: Modifier = Modifier, - sheetState: SheetState = rememberModalBottomSheetState(), + sheetState: SheetState = rememberBottomSheetState(initialValue = SheetValue.Hidden), contentWindowInsets: @Composable () -> WindowInsets = { BottomSheetDefaults.modalWindowInsets.only(WindowInsetsSides.Horizontal) }, title: String, content: @Composable ColumnScope.() -> Unit ) { + LaunchedEffect(Unit) { + if (sheetState.hasPartiallyExpandedState) { + sheetState.partialExpand() + } else { + sheetState.expand() + } + } ModalBottomSheet( onDismissRequest = onDismissRequest, modifier = modifier.windowInsetsPadding(WindowInsets.statusBars), From 1d0377ed03e0d7a57c0baaa6e78e31898341547b Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Mon, 15 Jun 2026 23:38:19 +0100 Subject: [PATCH 15/26] Tweak info sheet title --- .../main/java/moe/apex/breadboard/largeimageview/InfoSheet.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/moe/apex/breadboard/largeimageview/InfoSheet.kt b/app/src/main/java/moe/apex/breadboard/largeimageview/InfoSheet.kt index bcd0511d..c19e87fc 100644 --- a/app/src/main/java/moe/apex/breadboard/largeimageview/InfoSheet.kt +++ b/app/src/main/java/moe/apex/breadboard/largeimageview/InfoSheet.kt @@ -175,7 +175,7 @@ fun InfoSheet(navController: NavController, image: Image, onDismissRequest: () - TitledModalBottomSheet( onDismissRequest = onDismissRequest, sheetState = sheetState, - title = "About this image", + title = "About this art", ) { if (selectedTag != null) { /* We need to have this dialog inside the sheet otherwise it'll just automatically From 78c429de1cf83c5b7bf2d856fa7adf5789211fb0 Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Tue, 16 Jun 2026 02:34:16 +0100 Subject: [PATCH 16/26] Add MD5 fallback for fetching grouped tags for very old favourites --- app/src/main/java/moe/apex/breadboard/image/Image.kt | 8 +++++--- .../main/java/moe/apex/breadboard/image/ImageBoard.kt | 10 ++++++++-- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/moe/apex/breadboard/image/Image.kt b/app/src/main/java/moe/apex/breadboard/image/Image.kt index 4457e45e..08829866 100644 --- a/app/src/main/java/moe/apex/breadboard/image/Image.kt +++ b/app/src/main/java/moe/apex/breadboard/image/Image.kt @@ -73,9 +73,11 @@ data class Image( val hasGroupedTags: Boolean get() { - /* If the image does not have ID or metadata, we would have no way of fetching additional info. - Therefore, the image should just be treated as already having grouped tags. */ - if (id == null || metadata == null) return true + /* We use this to determine whether we should fetch updated (grouped) tags for a post. + On Gelbooru this required the tags themselves, which won't exist if metadata is null. + That adds a bit of complexity that we need to handle elsewhere, + but it keeps this property 'truthful' so to speak. */ + if (metadata == null) return false /* Usually, we can tell that the existing grouped tags are grouped properly if they have more than one group. diff --git a/app/src/main/java/moe/apex/breadboard/image/ImageBoard.kt b/app/src/main/java/moe/apex/breadboard/image/ImageBoard.kt index df524588..d844ba9b 100644 --- a/app/src/main/java/moe/apex/breadboard/image/ImageBoard.kt +++ b/app/src/main/java/moe/apex/breadboard/image/ImageBoard.kt @@ -283,7 +283,8 @@ object Rule34 : GelbooruBasedImageBoard { } override suspend fun loadImageGroupedTags(image: Image, auth: ImageBoardAuth?): ImageMetadata? { - return image.id?.let { loadImage(it, auth)?.metadata } + val img = image.id?.let { loadImage(it, auth) } ?: loadImageMd5(image.fileName, auth) + return img?.metadata } } @@ -311,6 +312,9 @@ object Safebooru : GelbooruBasedImageBoard { } override suspend fun loadImageGroupedTags(image: Image, auth: ImageBoardAuth?): ImageMetadata? { + /* We can't use MD5 as a fallback here because on Safebooru, + the fileName (which Breadboard stores) is different from the MD5 hash, + and I haven't found a way to search using the filename. */ return image.id?.let { loadImage(it, auth)?.metadata } } } @@ -488,9 +492,11 @@ object Danbooru : ImageBoard { } override suspend fun loadImageGroupedTags(image: Image, auth: ImageBoardAuth?): ImageMetadata? { - return image.id?.let { loadImage(it, auth)?.metadata } + val img = image.id?.let { loadImage(it, auth) } ?: loadImageMd5(image.fileName, auth) + return img?.metadata } + override fun getRatingFromString(rating: String): ImageRating { return when (rating) { "g" -> ImageRating.SAFE From d90bb9e8e04307b93924491bb175543215c361b6 Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Tue, 16 Jun 2026 03:56:44 +0100 Subject: [PATCH 17/26] More simple and robust fix for gelbooru thumbnails --- .../moe/apex/breadboard/preferences/Pref.kt | 44 ++++++++----------- .../main/java/moe/apex/breadboard/util/Uri.kt | 9 ++++ 2 files changed, 28 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/moe/apex/breadboard/preferences/Pref.kt b/app/src/main/java/moe/apex/breadboard/preferences/Pref.kt index bd1d6136..7998919b 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/Pref.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/Pref.kt @@ -51,6 +51,7 @@ import moe.apex.breadboard.util.PixivArtwork import moe.apex.breadboard.util.SecretsManager import moe.apex.breadboard.util.availableRatingsForSource import moe.apex.breadboard.util.decodeHtml +import moe.apex.breadboard.util.replaceGelbooruSubdomain import java.io.IOException @@ -419,31 +420,6 @@ class UserPreferencesRepository(private val dataStore: DataStore) { updatePref(PreferenceKeys.FILTER_RATINGS_LOCALLY, false) } - /* Version code 270 enabled the staggered grid by default. Don't change it for people who - hadn't enabled it previously. - Additionally, fix Gelbooru favourite image links since their subdomain changed from img3 - to img4. */ - if (lastUsedVersionCode < 270) { - val data = dataStore.data.first() - val brokenFavouritesByteArray = data[PreferenceKeys.FAVOURITE_IMAGES] - - if (brokenFavouritesByteArray != null) { - val brokenFavourites: List = Cbor.decodeFromByteArray(brokenFavouritesByteArray) - val tempFavourites = brokenFavourites.toMutableList() - brokenFavourites.forEachIndexed { index, img -> - if (img.imageSource == ImageSource.GELBOORU) { - val fixedImage = img.copy( - previewUrl = img.previewUrl.replace("img3.gelbooru", "img4.gelbooru"), - fileUrl = img.fileUrl.replace("img3.gelbooru", "img4.gelbooru"), - sampleUrl = img.sampleUrl.replace("img3.gelbooru", "img4.gelbooru") - ) - tempFavourites[index] = fixedImage - } - } - updateFavouriteImages(tempFavourites) - } - } - // v3 (code 300) migrations start here /* Versions 270 and 271 had a bug where staggered might still be disabled by default for new @@ -552,6 +528,24 @@ class UserPreferencesRepository(private val dataStore: DataStore) { prefs.remove(key) } } + + val favouritesByteArray = data[PreferenceKeys.FAVOURITE_IMAGES] + + if (favouritesByteArray != null) { + val favouriteImages: List = Cbor.decodeFromByteArray(favouritesByteArray) + val tempFavourites = favouriteImages.toMutableList() + tempFavourites.forEachIndexed { index, img -> + if (img.imageSource == ImageSource.GELBOORU) { + val fixedImage = img.copy( + previewUrl = replaceGelbooruSubdomain(img.previewUrl), + fileUrl = replaceGelbooruSubdomain(img.fileUrl), + sampleUrl = replaceGelbooruSubdomain(img.sampleUrl) + ) + tempFavourites[index] = fixedImage + } + } + updateFavouriteImages(tempFavourites) + } } /* Always clear the internal ignored list on updates. */ diff --git a/app/src/main/java/moe/apex/breadboard/util/Uri.kt b/app/src/main/java/moe/apex/breadboard/util/Uri.kt index a40a9791..c670ae43 100644 --- a/app/src/main/java/moe/apex/breadboard/util/Uri.kt +++ b/app/src/main/java/moe/apex/breadboard/util/Uri.kt @@ -33,3 +33,12 @@ fun launchInWebBrowser(context: Context, uri: Uri) { launchUriWithPackage(context, uri, defaultPackage) } + + +fun replaceGelbooruSubdomain(url: String): String { + val uri = url.toUri() + return uri.buildUpon() + .authority("img4.gelbooru.com") + .build() + .toString() +} From 2ba34f4d86f87ba22a2d9096676b760be2336aed Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Tue, 16 Jun 2026 03:59:18 +0100 Subject: [PATCH 18/26] Imports --- app/src/main/java/moe/apex/breadboard/search/SearchScreen.kt | 5 ----- 1 file changed, 5 deletions(-) diff --git a/app/src/main/java/moe/apex/breadboard/search/SearchScreen.kt b/app/src/main/java/moe/apex/breadboard/search/SearchScreen.kt index dedc55ce..e84164b2 100644 --- a/app/src/main/java/moe/apex/breadboard/search/SearchScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/search/SearchScreen.kt @@ -21,12 +21,9 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -85,10 +82,8 @@ import androidx.compose.ui.draw.scale import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization From d819f745c63d0c0d44e1d9f563ca1f802d82eee4 Mon Sep 17 00:00:00 2001 From: Flaze <51487578+flazepe@users.noreply.github.com> Date: Tue, 16 Jun 2026 11:02:35 +0800 Subject: [PATCH 19/26] Account for an additional hash in pixiv image URLs (#57) --- app/src/main/java/moe/apex/breadboard/util/Pixiv.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/moe/apex/breadboard/util/Pixiv.kt b/app/src/main/java/moe/apex/breadboard/util/Pixiv.kt index 163cfbf1..fd57a33b 100644 --- a/app/src/main/java/moe/apex/breadboard/util/Pixiv.kt +++ b/app/src/main/java/moe/apex/breadboard/util/Pixiv.kt @@ -4,7 +4,8 @@ package moe.apex.breadboard.util private val PIXIV_CURRENT_RX = // https://i.pximg.net/img-original/img/2022/11/27/21/27/08/103150283_p0.jpg (Safebooru #6517847) // https://i.pximg.net/img-master/img/2019/07/09/08/27/59/75629295_p0_master1200.jpg (Safebooru #3567627) - """https?://i\.pximg\.net/img-(original|master)/img/\d+/\d+/\d+/\d+/\d+/\d+/(?\d+)_p(?\d+)(_master1200)?\.(png|jpg|jpeg|gif)""".toRegex() + // https://i.pximg.net/img-original/img/2026/05/13/20/30/05/144732134-2ec1cc9314f12cef1751801f82cc21a0_p0.jpg (Safebooru #6757725) + """https?://i\.pximg\.net/img-(original|master)/img/\d+/\d+/\d+/\d+/\d+/\d+/(?\d+)(-[0-9a-f]+)?_p(?\d+)(_master1200)?\.(png|jpg|jpeg|gif)""".toRegex() // 2012-2016 pixiv direct image URLs private val PIXIV_2012_TO_2016_RX = listOf( From faf3579c574aa3a88800480384ecc21ad7b1401c Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Fri, 19 Jun 2026 02:23:34 +0100 Subject: [PATCH 20/26] Move blocked tags func to fix first search load --- .../main/java/moe/apex/breadboard/detailview/SearchResults.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/moe/apex/breadboard/detailview/SearchResults.kt b/app/src/main/java/moe/apex/breadboard/detailview/SearchResults.kt index fada6c05..69a03950 100644 --- a/app/src/main/java/moe/apex/breadboard/detailview/SearchResults.kt +++ b/app/src/main/java/moe/apex/breadboard/detailview/SearchResults.kt @@ -104,9 +104,9 @@ fun SearchResults(navController: NavController, source: ImageSource, tagList: Li } if (!isReady) { - updateBlockedTags() // Subsequent calls are done in the pull to refresh callback. viewModel.updateSelectedRatings(prefs.ratingsFilter) setUpViewModel(auth) + updateBlockedTags() // Subsequent calls are done in the pull to refresh callback. } } From 8059f20ed5860c78be88d326d683ff81c51a8eae Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Fri, 19 Jun 2026 02:46:51 +0100 Subject: [PATCH 21/26] Unify all AI blocking under the EXCLUDE_AI pref --- .../breadboard/largeimageview/InfoSheet.kt | 30 ++++++++++++++----- .../preferences/BlockedTagsScreen.kt | 16 ++++++---- .../moe/apex/breadboard/preferences/Pref.kt | 12 ++++++++ 3 files changed, 45 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/moe/apex/breadboard/largeimageview/InfoSheet.kt b/app/src/main/java/moe/apex/breadboard/largeimageview/InfoSheet.kt index c19e87fc..26cddca1 100644 --- a/app/src/main/java/moe/apex/breadboard/largeimageview/InfoSheet.kt +++ b/app/src/main/java/moe/apex/breadboard/largeimageview/InfoSheet.kt @@ -234,18 +234,32 @@ fun InfoSheet(navController: NavController, image: Image, onDismissRequest: () - ) { if (blocked) { scope.launch { - preferencesRepository.removeFromSet( - PreferenceKeys.MANUALLY_BLOCKED_TAGS, - selectedTag!! - ) + if (selectedTag!! in AI_TAG_NAMES) { + preferencesRepository.updatePref( + PreferenceKeys.EXCLUDE_AI, + false + ) + } else { + preferencesRepository.removeFromSet( + PreferenceKeys.MANUALLY_BLOCKED_TAGS, + selectedTag!! + ) + } } showToast(context, "Unblocked tag ${selectedTag!!}") } else { scope.launch { - preferencesRepository.addToSet( - PreferenceKeys.MANUALLY_BLOCKED_TAGS, - selectedTag!! - ) + if (selectedTag in AI_TAG_NAMES) { + preferencesRepository.updatePref( + PreferenceKeys.EXCLUDE_AI, + true + ) + } else { + preferencesRepository.addToSet( + PreferenceKeys.MANUALLY_BLOCKED_TAGS, + selectedTag!! + ) + } } showToast(context, "Blocked tag ${selectedTag!!}") } diff --git a/app/src/main/java/moe/apex/breadboard/preferences/BlockedTagsScreen.kt b/app/src/main/java/moe/apex/breadboard/preferences/BlockedTagsScreen.kt index 3b5f06f5..4df32be0 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/BlockedTagsScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/BlockedTagsScreen.kt @@ -96,13 +96,19 @@ fun BlockedTagsScreen(navController: NavHostController) { .trim() .split(" ") .filter { it.isNotBlank() } - .filterNot { it in AI_TAG_NAMES } scope.launch { for (tag in newBlocks) { - userPreferencesRepository.addToSet( - PreferenceKeys.MANUALLY_BLOCKED_TAGS, - tag - ) + if (tag in AI_TAG_NAMES) { + userPreferencesRepository.updatePref( + PreferenceKeys.EXCLUDE_AI, + true + ) + } else { + userPreferencesRepository.addToSet( + PreferenceKeys.MANUALLY_BLOCKED_TAGS, + tag + ) + } } } showAddDialog = false diff --git a/app/src/main/java/moe/apex/breadboard/preferences/Pref.kt b/app/src/main/java/moe/apex/breadboard/preferences/Pref.kt index 7998919b..ef0db442 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/Pref.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/Pref.kt @@ -548,6 +548,18 @@ class UserPreferencesRepository(private val dataStore: DataStore) { } } + /* Version 3.2.3 removes AI tags from the manual section in favour of handling them all + with the EXCLUDE_AI pref. */ + if (lastUsedVersionCode < 323) { + val data = dataStore.data.first() + val blockedTags = data[PreferenceKeys.MANUALLY_BLOCKED_TAGS] ?: emptySet() + val blockedTagsWithoutAi = blockedTags.filterNot { it in AI_TAG_NAMES } + if (blockedTags.size != blockedTagsWithoutAi.size) { + updateSet(PreferenceKeys.MANUALLY_BLOCKED_TAGS, blockedTagsWithoutAi) + updatePref(PreferenceKeys.EXCLUDE_AI, true) + } + } + /* Always clear the internal ignored list on updates. */ if (BuildConfig.VERSION_CODE != lastUsedVersionCode) { dataStore.edit { prefs -> From 4b939342a3b91784588a0296e1eeb1467a730f09 Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Fri, 19 Jun 2026 03:03:06 +0100 Subject: [PATCH 22/26] update version --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 1e841c8f..c7d34750 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -20,8 +20,8 @@ android { applicationId "moe.apex.breadboard" minSdk 26 targetSdk 36 - versionCode 322 - versionName "3.2.2" + versionCode 323 + versionName "3.2.3" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From 4e47a970fef57cb776695e730c0d8cbdc0ef62c2 Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Fri, 19 Jun 2026 03:19:40 +0100 Subject: [PATCH 23/26] Expressive loading indicators --- .../apex/breadboard/detailview/ImageGrid.kt | 7 ++----- .../moe/apex/breadboard/home/HomeScreen.kt | 4 ++-- .../main/java/moe/apex/breadboard/util/Ui.kt | 18 ++++++++++++++---- 3 files changed, 18 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/moe/apex/breadboard/detailview/ImageGrid.kt b/app/src/main/java/moe/apex/breadboard/detailview/ImageGrid.kt index dec28e38..69b670e6 100644 --- a/app/src/main/java/moe/apex/breadboard/detailview/ImageGrid.kt +++ b/app/src/main/java/moe/apex/breadboard/detailview/ImageGrid.kt @@ -31,7 +31,6 @@ import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan import androidx.compose.foundation.lazy.staggeredgrid.itemsIndexed import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.pulltorefresh.PullToRefreshBox @@ -58,6 +57,7 @@ import moe.apex.breadboard.preferences.LocalPreferences import moe.apex.breadboard.util.NavBarHeightVerticalSpacer import moe.apex.breadboard.util.PullToRefreshController import moe.apex.breadboard.util.SMALL_SPACER +import moe.apex.breadboard.util.WideLinearWavyProgressIndicator import moe.apex.breadboard.util.largerShape @@ -138,10 +138,7 @@ fun ImageGrid( .padding(contentPadding) .background(MaterialTheme.colorScheme.background) ) { - LinearProgressIndicator( - modifier = modifier - .fillMaxWidth() - ) + WideLinearWavyProgressIndicator(modifier = modifier.fillMaxWidth()) } LaunchedEffect(doneInitialLoad) { diff --git a/app/src/main/java/moe/apex/breadboard/home/HomeScreen.kt b/app/src/main/java/moe/apex/breadboard/home/HomeScreen.kt index 40723283..351f892f 100644 --- a/app/src/main/java/moe/apex/breadboard/home/HomeScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/home/HomeScreen.kt @@ -10,7 +10,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon -import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBarDefaults @@ -44,6 +43,7 @@ import moe.apex.breadboard.util.RecommendationsProvider import moe.apex.breadboard.util.SMALL_LARGE_SPACER import moe.apex.breadboard.util.SMALL_SPACER import moe.apex.breadboard.util.ScrollToTopArrow +import moe.apex.breadboard.util.WideLinearWavyProgressIndicator import moe.apex.breadboard.util.bottomAppBarAndNavBarHeight import moe.apex.breadboard.util.differenceOlderThan import moe.apex.breadboard.util.onScroll @@ -121,7 +121,7 @@ fun HomeScreen( } if (builtInIgnoredTags.isEmpty()) { - LinearProgressIndicator( + WideLinearWavyProgressIndicator( modifier = Modifier .fillMaxWidth() .padding(padding) diff --git a/app/src/main/java/moe/apex/breadboard/util/Ui.kt b/app/src/main/java/moe/apex/breadboard/util/Ui.kt index 314c8833..31c0a62d 100644 --- a/app/src/main/java/moe/apex/breadboard/util/Ui.kt +++ b/app/src/main/java/moe/apex/breadboard/util/Ui.kt @@ -77,6 +77,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.LargeTopAppBar +import androidx.compose.material3.LinearWavyProgressIndicator import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet @@ -1250,17 +1251,16 @@ fun rememberPullToRefreshController( object PullToRefreshControllerDefaults { + @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun Indicator( modifier: Modifier = Modifier, controller: PullToRefreshController, ) { - PullToRefreshDefaults.Indicator( + PullToRefreshDefaults.LoadingIndicator( state = controller.state, isRefreshing = controller.isRefreshing, - modifier = modifier, - containerColor = MaterialTheme.colorScheme.tertiaryContainer, - color = MaterialTheme.colorScheme.onTertiaryContainer + modifier = modifier ) } } @@ -1445,6 +1445,16 @@ fun rememberIsBlurEnabled(): Boolean { } +@Composable +fun WideLinearWavyProgressIndicator(modifier: Modifier = Modifier) { + LinearWavyProgressIndicator( + modifier = modifier, + amplitude = 0.6f, + wavelength = 40.dp, + ) +} + + val bottomAppBarAndNavBarHeight: Dp @Composable get() = BOTTOM_APP_BAR_HEIGHT.dp + navBarHeight From cb2d7453b5cf936d41fa0d87b31ec9631a439d70 Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Fri, 19 Jun 2026 03:28:25 +0100 Subject: [PATCH 24/26] Remove note about discontinued legacy builds --- README.md | 5 ----- 1 file changed, 5 deletions(-) diff --git a/README.md b/README.md index f9880972..1c4db971 100644 --- a/README.md +++ b/README.md @@ -67,11 +67,6 @@ Powered by multiple image boards and boorus, Breadboard makes finding your favou Alternatively, download [the latest GitHub release](https://github.com/breadboardapp/breadboard/releases/latest), allow unknown sources in your device settings, and then install manually. This method will not provide automatic/notifications for updates. -> [!WARNING] -> Legacy builds (with the `-legacy` suffix) are deprecated we will cease publishing them in the near future. -> -> Make sure you download a standard release (with no suffix). - ## Contributing Feedback and code contributions are welcome! From 3341f569d8335e664e00dcb4d48781612b463279 Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Fri, 19 Jun 2026 03:35:22 +0100 Subject: [PATCH 25/26] compose and m3 update --- gradle/libs.versions.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d33d6af9..ae077e37 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,8 +7,8 @@ okhttp = "5.4.0" kotlin-bom = "2.4.0" activity-compose = "1.13.0" core-ktx = "1.19.0" -compose-bom = "2026.05.01" -material3 = "1.5.0-alpha21" +compose-bom = "2026.06.00" +material3 = "1.5.0-alpha22" documentfile = "1.1.0" navigation-compose = "2.9.8" paging-compose = "3.5.0" From d09b66c247240c2364e03d0c80081e507e7154db Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Fri, 19 Jun 2026 03:49:49 +0100 Subject: [PATCH 26/26] Adjust horizontal window insets --- app/src/main/java/moe/apex/breadboard/util/Ui.kt | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/moe/apex/breadboard/util/Ui.kt b/app/src/main/java/moe/apex/breadboard/util/Ui.kt index 31c0a62d..afd67a32 100644 --- a/app/src/main/java/moe/apex/breadboard/util/Ui.kt +++ b/app/src/main/java/moe/apex/breadboard/util/Ui.kt @@ -82,6 +82,7 @@ import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Scaffold +import androidx.compose.material3.ScaffoldDefaults import androidx.compose.material3.SelectableChipColors import androidx.compose.material3.SheetState import androidx.compose.material3.SheetValue @@ -237,7 +238,8 @@ fun LargeTitleBar( navigationIcon = { NavigationIcon(navController) }, colors = TopAppBarDefaults.topAppBarColors().copy( scrolledContainerColor = BreadboardTheme.colors.titleBar - ) + ), + windowInsets = TopAppBarDefaults.windowInsets.only(WindowInsetsSides.Vertical) ) } @@ -257,7 +259,8 @@ fun SmallTitleBar( colors = TopAppBarDefaults.topAppBarColors( scrolledContainerColor = BreadboardTheme.colors.titleBar ), - navigationIcon = { NavigationIcon(navController) } + navigationIcon = { NavigationIcon(navController) }, + windowInsets = TopAppBarDefaults.windowInsets.only(WindowInsetsSides.Vertical) ) } @@ -357,7 +360,8 @@ fun MainScreenScaffold( it() } } - } + }, + contentWindowInsets = ScaffoldDefaults.contentWindowInsets.only(WindowInsetsSides.Vertical) ) { val lld = LocalLayoutDirection.current val newPadding = PaddingValues(