From b845bb53cb62a5b24237e1d401257d3b3f981051 Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Tue, 30 Jun 2026 23:13:01 +0100 Subject: [PATCH 01/59] Implement artist profiles, remake ImageGrid. Unfinished. --- app/src/main/AndroidManifest.xml | 18 + .../moe/apex/breadboard/DeepLinkActivity.kt | 40 +- .../java/moe/apex/breadboard/MainActivity.kt | 21 +- .../java/moe/apex/breadboard/artist/Artist.kt | 27 + .../breadboard/artist/ArtistProfileScreen.kt | 654 ++++++++++++++++++ .../apex/breadboard/artist/ArtistSocial.kt | 63 ++ .../moe/apex/breadboard/artist/ArtistTag.kt | 10 + .../apex/breadboard/detailview/ImageGrid.kt | 257 +++++-- .../moe/apex/breadboard/image/ImageBoard.kt | 57 ++ .../breadboard/largeimageview/InfoSheet.kt | 98 +-- .../breadboard/navigation/Destinations.kt | 21 + .../apex/breadboard/navigation/Navigation.kt | 9 +- .../main/java/moe/apex/breadboard/util/Ui.kt | 22 +- .../viewmodel/ArtistProfileViewModel.kt | 43 ++ app/src/main/res/drawable/ic_bilibili.xml | 20 + app/src/main/res/drawable/ic_bluesky.xml | 20 + app/src/main/res/drawable/ic_discord.xml | 20 + app/src/main/res/drawable/ic_facebook.xml | 20 + app/src/main/res/drawable/ic_instagram.xml | 20 + app/src/main/res/drawable/ic_link.xml | 20 + app/src/main/res/drawable/ic_lofter.xml | 20 + app/src/main/res/drawable/ic_mastodon.xml | 20 + app/src/main/res/drawable/ic_patreon.xml | 20 + app/src/main/res/drawable/ic_pixiv.xml | 25 + app/src/main/res/drawable/ic_reddit.xml | 20 + app/src/main/res/drawable/ic_skeb.xml | 20 + app/src/main/res/drawable/ic_threads.xml | 20 + app/src/main/res/drawable/ic_tiktok.xml | 20 + app/src/main/res/drawable/ic_twitter.xml | 20 + app/src/main/res/drawable/ic_weibo.xml | 25 + app/src/main/res/drawable/ic_youtube.xml | 20 + .../res/font/special_gothic_one_expanded.ttf | Bin 0 -> 101024 bytes 32 files changed, 1574 insertions(+), 116 deletions(-) create mode 100644 app/src/main/java/moe/apex/breadboard/artist/Artist.kt create mode 100644 app/src/main/java/moe/apex/breadboard/artist/ArtistProfileScreen.kt create mode 100644 app/src/main/java/moe/apex/breadboard/artist/ArtistSocial.kt create mode 100644 app/src/main/java/moe/apex/breadboard/artist/ArtistTag.kt create mode 100644 app/src/main/java/moe/apex/breadboard/viewmodel/ArtistProfileViewModel.kt create mode 100644 app/src/main/res/drawable/ic_bilibili.xml create mode 100644 app/src/main/res/drawable/ic_bluesky.xml create mode 100644 app/src/main/res/drawable/ic_discord.xml create mode 100644 app/src/main/res/drawable/ic_facebook.xml create mode 100644 app/src/main/res/drawable/ic_instagram.xml create mode 100644 app/src/main/res/drawable/ic_link.xml create mode 100644 app/src/main/res/drawable/ic_lofter.xml create mode 100644 app/src/main/res/drawable/ic_mastodon.xml create mode 100644 app/src/main/res/drawable/ic_patreon.xml create mode 100644 app/src/main/res/drawable/ic_pixiv.xml create mode 100644 app/src/main/res/drawable/ic_reddit.xml create mode 100644 app/src/main/res/drawable/ic_skeb.xml create mode 100644 app/src/main/res/drawable/ic_threads.xml create mode 100644 app/src/main/res/drawable/ic_tiktok.xml create mode 100644 app/src/main/res/drawable/ic_twitter.xml create mode 100644 app/src/main/res/drawable/ic_weibo.xml create mode 100644 app/src/main/res/drawable/ic_youtube.xml create mode 100644 app/src/main/res/font/special_gothic_one_expanded.ttf diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 715f2d5c..6b98fc7f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -30,6 +30,15 @@ android:launchMode="singleTop" android:exported="true" android:theme="@style/Theme.Breadboard"> + + + + + + + + + @@ -118,6 +127,15 @@ + + + + + + + + + { newIntent -> - val uri = newIntent.data - if (uri != null) { + val uri = newIntent.data ?: return@Consumer + + if (uri.scheme == "breadboard") { + handleBreadboardScheme(uri)?.let { + navController.popBackStack() + navController.navigate(it) + } ?: finishAndRemoveTask() + } else { ImageView.fromUri(uri)?.let { navController.popBackStack() navController.navigate(it) + } ?: ArtistProfile.fromUri(uri)?.let { + navController.popBackStack() + navController.navigate(it) } ?: reopenInBrowser(newIntent) } } @@ -93,15 +104,34 @@ class DeepLinkActivity : SingletonImageLoader.Factory, ComponentActivity(), Volu onDispose { removeOnNewIntentListener(listener) } } intent.data?.let { - ImageView.fromUri(it)?.let { iv -> - Navigation(navController = navController, startDestination = iv) - } ?: reopenInBrowser(intent) + if (it.scheme == "breadboard") { + handleBreadboardScheme(it)?.let { dest -> + Navigation(navController = navController, startDestination = dest) + } ?: finishAndRemoveTask() + } else { + ImageView.fromUri(it)?.let { iv -> + Navigation(navController = navController, startDestination = iv) + } ?: ArtistProfile.fromUri(it)?.let { ap -> + Navigation(navController = navController, startDestination = ap) + } ?: reopenInBrowser(intent) + } } ?: finishAndRemoveTask() } } } + private fun handleBreadboardScheme(uri: Uri): Any? { + val path = uri.pathSegments + if (uri.host == "artist") { + path.getOrNull(0)?.let { + return ArtistProfile(it) + } + } + return null + } + + private fun reopenInBrowser(intent: Intent) { val uri = intent.data!! diff --git a/app/src/main/java/moe/apex/breadboard/MainActivity.kt b/app/src/main/java/moe/apex/breadboard/MainActivity.kt index db5ef6f6..4cf3bca9 100644 --- a/app/src/main/java/moe/apex/breadboard/MainActivity.kt +++ b/app/src/main/java/moe/apex/breadboard/MainActivity.kt @@ -29,6 +29,7 @@ import coil3.gif.AnimatedImageDecoder import coil3.gif.GifDecoder import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking +import moe.apex.breadboard.navigation.ArtistProfile import moe.apex.breadboard.navigation.Favourites import moe.apex.breadboard.navigation.Home import moe.apex.breadboard.navigation.Navigation @@ -79,6 +80,22 @@ class MainActivity : SingletonImageLoader.Factory, ComponentActivity(), VolumeBu } + private fun maybePrepareArtistDestination(intent: Intent): ArtistProfile? { + return intent.getStringExtra("artist")?.let { + return ArtistProfile(it) + } + } + + + private fun determineDestination(intent: Intent): Any? { + return when (intent.getStringExtra("destination")) { + "artist" -> maybePrepareArtistDestination(intent) + "search" -> maybePrepareResultsDestination(intent) + else -> null + } + } + + @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() @@ -120,7 +137,7 @@ class MainActivity : SingletonImageLoader.Factory, ComponentActivity(), VolumeBu to be done inside of this activity rather than the DeepLinkActivity. */ DisposableEffect(Unit) { val innerListener = Consumer { intent -> - maybePrepareResultsDestination(intent)?.let { + determineDestination(intent)?.let { navController.navigate(it) } } @@ -128,7 +145,7 @@ class MainActivity : SingletonImageLoader.Factory, ComponentActivity(), VolumeBu onDispose { removeOnNewIntentListener(innerListener) } } FlagSecureHelper.register() - Navigation(navController, maybePrepareResultsDestination(intent) ?: startDestination) + Navigation(navController, determineDestination(intent) ?: startDestination) } } } diff --git a/app/src/main/java/moe/apex/breadboard/artist/Artist.kt b/app/src/main/java/moe/apex/breadboard/artist/Artist.kt new file mode 100644 index 00000000..3510ef6f --- /dev/null +++ b/app/src/main/java/moe/apex/breadboard/artist/Artist.kt @@ -0,0 +1,27 @@ +package moe.apex.breadboard.artist + +import androidx.compose.runtime.Immutable + + +@Immutable +data class Artist( + val name: String, + val otherNames: List, + val socialUrls: List, + val tag: ArtistTag +) { + fun groupSocials(): Map> { + return socialUrls.groupBy { SocialSite.fromUrl(it.url) } + .toList() + .sortedWith(compareBy>> { + when (it.first) { // Site priority, always show Pixiv/Fanbox/Twitter first and OTHER last. + SocialSite.PIXIV -> 0 + SocialSite.FANBOX -> 1 + SocialSite.TWITTER -> 2 + SocialSite.OTHER -> 4 + else -> 3 + } + }.thenBy { it.first.label.lowercase() }) // And then every other site is alphabetical. + .toMap() + } +} diff --git a/app/src/main/java/moe/apex/breadboard/artist/ArtistProfileScreen.kt b/app/src/main/java/moe/apex/breadboard/artist/ArtistProfileScreen.kt new file mode 100644 index 00000000..21d87630 --- /dev/null +++ b/app/src/main/java/moe/apex/breadboard/artist/ArtistProfileScreen.kt @@ -0,0 +1,654 @@ +package moe.apex.breadboard.artist + +import android.content.Context +import android.content.Intent +import android.os.Build +import android.util.Log +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.fadeIn +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.plus +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.foundation.text.TextAutoSize +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Block +import androidx.compose.material.icons.rounded.ContentCopy +import androidx.compose.material.icons.rounded.KeyboardArrowDown +import androidx.compose.material.icons.rounded.MoreVert +import androidx.compose.material.icons.rounded.Search +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonGroup +import androidx.compose.material3.CircularWavyProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExtendedFloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.InputChip +import androidx.compose.material3.InputChipDefaults +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.ToggleButton +import androidx.compose.material3.ToggleButtonDefaults +import androidx.compose.material3.ToggleButtonShapes +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.blur +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import coil3.compose.AsyncImage +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.launch +import moe.apex.breadboard.R +import moe.apex.breadboard.detailview.FlexibleImageGrid +import moe.apex.breadboard.image.Image +import moe.apex.breadboard.largeimageview.OffsetBasedLargeImageView +import moe.apex.breadboard.navigation.Results +import moe.apex.breadboard.preferences.Experiment +import moe.apex.breadboard.preferences.ImageSource +import moe.apex.breadboard.preferences.LocalPreferences +import moe.apex.breadboard.preferences.PreferenceKeys +import moe.apex.breadboard.prefs +import moe.apex.breadboard.ui.theme.BreadboardTheme +import moe.apex.breadboard.util.LARGE_SPACER +import moe.apex.breadboard.util.LargeVerticalSpacer +import moe.apex.breadboard.util.MEDIUM_SPACER +import moe.apex.breadboard.util.MainScreenScaffold +import moe.apex.breadboard.util.NavigationIcon +import moe.apex.breadboard.util.SMALL_LARGE_SPACER +import moe.apex.breadboard.util.SMALL_SPACER +import moe.apex.breadboard.util.Summary +import moe.apex.breadboard.util.TINY_SPACER +import moe.apex.breadboard.util.copyText +import moe.apex.breadboard.util.showToast +import moe.apex.breadboard.viewmodel.ArtistProfileViewModel +import kotlin.random.Random + + +/* The annoying work with the Navigation Icon here is because the normal TopAppBars still fill the + status bar when collapsed, and I want the content to scroll beneath it because it looks much + more polished. */ +@Composable +fun ArtistProfileScreen( + artistTag: String, + viewModel: ArtistProfileViewModel = viewModel(key = artistTag) { ArtistProfileViewModel(artistTag) }, + navController: NavController +) { + val artist by viewModel.artistProfile.collectAsState() + val images by viewModel.images.collectAsState() + val isInitialised by viewModel.isInitialised.collectAsState() + + val context = LocalContext.current + val prefs = LocalPreferences.current + + var shouldShowLargeImage by remember { mutableStateOf(false) } + var selectedImageIndex by remember { mutableIntStateOf(0) } + + LaunchedEffect(artistTag) { + try { + viewModel.loadArtistProfile() + } catch (_: CancellationException) { + return@LaunchedEffect + } catch (e: Exception) { + Log.e("ArtistProfileScreen", "Error loading artist profile", e) + } + + if (artist == null) { + showToast(context, "Couldn't fetch profile. Performing search instead...") + navController.popBackStack() + navController.navigate(Results(ImageSource.DANBOORU, listOf(artistTag))) + } + } + + MainScreenScaffold( + topAppBar = { }, + blur = shouldShowLargeImage && prefs.isExperimentEnabled(Experiment.IMMERSIVE_UI_EFFECTS), + addBottomPadding = false, + floatingActionButton = { + AnimatedVisibility( + visible = isInitialised && artist != null, + enter = fadeIn(), + exit = ExitTransition.None + ) { + ExtendedFloatingActionButton( + text = { Text("View all posts") }, + icon = { + Icon( + imageVector = Icons.Rounded.Search, + contentDescription = null + ) + }, + onClick = { + navController.navigate(Results(ImageSource.DANBOORU, listOf(artistTag))) + } + ) + } + } + ) { + if (!isInitialised || artist == null) { + Box(modifier = Modifier.fillMaxSize()) { + NavigationButtonBox( + modifier = Modifier.padding(start = MEDIUM_SPACER.dp), + navController = navController + ) + + if (!isInitialised) { + CircularWavyProgressIndicator( + modifier = Modifier.align(Alignment.Center), + amplitude = 0.6f + ) + } + } + } else { + FlexibleImageGrid( + staggered = prefs.useStaggeredGrid, + contentPadding = PaddingValues( + start = SMALL_LARGE_SPACER.dp, + end = SMALL_LARGE_SPACER.dp, + bottom = 88.dp // FAB height + 16dp vertical padding + ), + images = images, + onImageClick = { index, _ -> + selectedImageIndex = index + shouldShowLargeImage = true + }, + headerItems = { + item { + NavigationButtonBox( + modifier = Modifier.offset(x = -TINY_SPACER.dp), + navController = navController + ) + } + item { + ArtistHeader(artist!!, images) + } + item { + ArtistToolbar(artistTag) + } + item { + PopularPostsHeading() + } + } + ) + } + } + + OffsetBasedLargeImageView( + navController = navController, + isActive = shouldShowLargeImage, + initialSelectedImageIndex = selectedImageIndex, + allImages = images, + onActiveStateChanged = { shouldShowLargeImage = it }, + onImageUpdate = { oldImage, newImage -> + viewModel.updateImage(oldImage, newImage) + } + ) +} + + +@Composable +private fun ArtistToolbar(artistTag: String, ) { + val context = LocalContext.current + val clipboard = LocalClipboard.current + val prefs = LocalPreferences.current + + val scope = rememberCoroutineScope() + val preferencesRepository = context.prefs + + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.secondaryContainer + ) { + Row( + modifier = Modifier.padding(SMALL_SPACER.dp), + horizontalArrangement = Arrangement.spacedBy(SMALL_SPACER.dp) + ) { + var isFollowing by remember { mutableStateOf(false) } // TODO Implement properly + + ButtonGroup( + overflowIndicator = { }, + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.spacedBy(TINY_SPACER.dp) + ) { + customItem( + buttonGroupContent = { + val interactionSource = remember { MutableInteractionSource() } + ToggleButton( + interactionSource = interactionSource, + checked = isFollowing, + modifier = Modifier + .height(48.dp) + .weight(3f) + .animateWidth(interactionSource), + onCheckedChange = { isFollowing = it }, + colors = ToggleButtonDefaults.toggleButtonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + checkedContainerColor = MaterialTheme.colorScheme.secondary, + checkedContentColor = MaterialTheme.colorScheme.onSecondary + ), + shapes = ToggleButtonShapes( + shape = CircleShape.copy( + topEnd = CornerSize(8.dp), + bottomEnd = CornerSize(8.dp) + ), + checkedShape = CircleShape.copy( + topEnd = CornerSize(8.dp), + bottomEnd = CornerSize(8.dp) + ), + pressedShape = CircleShape.copy( + topEnd = CornerSize(8.dp), + bottomEnd = CornerSize(8.dp) + ) + ) + ) { + Text( + text = if (!isFollowing) "Follow" else "Following" + ) + } + }, + menuContent = { } + ) + + customItem( + buttonGroupContent = { + val interactionSource = remember { MutableInteractionSource() } + Button( + interactionSource = interactionSource, + modifier = Modifier + .height(48.dp) + .weight(2f) + .animateWidth(interactionSource), + shape = CircleShape.copy( + topStart = CornerSize(8.dp), + bottomStart = CornerSize(8.dp) + ), + onClick = { + shareArtist( + context = context, + artistTag = artistTag, + addFriendlyMessage = true + ) + } + ) { + Text("Share") + } + }, + menuContent = { } + ) + } + + var showDropdown by remember { mutableStateOf(false) } + Box { + IconButton( + modifier = Modifier.size(48.dp), + onClick = { + showDropdown = !showDropdown + } + ) { + Icon( + imageVector = Icons.Rounded.MoreVert, + contentDescription = "More options" + ) + } + DropdownMenu( + expanded = showDropdown, + shape = MaterialTheme.shapes.medium, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + onDismissRequest = { showDropdown = false } + ) { + DropdownMenuItem( + onClick = { + scope.launch { + copyText( + context = context, + clipboard = clipboard, + text = "https://breadboard.moe/artist/$artistTag", + ) + } + }, + text = { Text("Copy link") }, + leadingIcon = { + Icon( + painter = painterResource(R.drawable.ic_link), + contentDescription = null + ) + } + ) + DropdownMenuItem( + onClick = { + scope.launch { + copyText( + context = context, + clipboard = clipboard, + text = artistTag, + ) + } + }, + text = { Text("Copy search tag") }, + leadingIcon = { + Icon( + imageVector = Icons.Rounded.ContentCopy, + contentDescription = null + ) + } + ) + DropdownMenuItem( + onClick = { + scope.launch { + if (artistTag in prefs.manuallyBlockedTags) { + preferencesRepository.removeFromSet( + PreferenceKeys.MANUALLY_BLOCKED_TAGS, + artistTag + ) + } else { + preferencesRepository.addToSet( + PreferenceKeys.MANUALLY_BLOCKED_TAGS, + artistTag + ) + } + } + }, + text = { + Text( + text = if (artistTag in prefs.manuallyBlockedTags) { + "Unblock artist" + } else "Block artist" + ) + }, + leadingIcon = { + Icon( + imageVector = Icons.Rounded.Block, + contentDescription = null + ) + } + ) + } + } + } + } +} + + +@Composable +private fun PopularPostsHeading() { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = LARGE_SPACER.dp, bottom = MEDIUM_SPACER.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(LARGE_SPACER.dp) + ) { + HorizontalDivider(Modifier.fillMaxWidth(0.5f)) + Text( + text = "Popular posts", + style = MaterialTheme.typography.titleLarge + ) + } +} + + +@Composable +private fun NavigationButtonBox( + modifier: Modifier = Modifier, + navController: NavController +) { + Box( + modifier = Modifier + .windowInsetsPadding(WindowInsets.statusBars) + .padding(bottom = SMALL_SPACER.dp) + .height(TopAppBarDefaults.LargeAppBarCollapsedHeight), + contentAlignment = Alignment.CenterStart + ) { + NavigationIcon( + modifier = modifier, + navController = navController + ) + } +} + + +@Composable +private fun ArtistHeader( + artist: Artist, + images: List, +) { + val uriHandler = LocalUriHandler.current + val randomImageIndex = rememberSaveable { Random.nextInt(images.size) } + + val groupedSocials = remember { artist.groupSocials() } + + BreadboardTheme(darkTheme = true) { // We're adding a dark scrim, so assume always dark theme. + Surface(shape = MaterialTheme.shapes.extraLargeIncreased) { + Box { + AsyncImage( + model = images[randomImageIndex].let { + if (it.isVideo || it.fileFormat == "gif" || it.fileFormat == "webp") { + it.previewUrl // Can't use videos, and we don't want animated images + } else { + it.sampleUrl // Highest quality isn't needed when we're blurring it anyway + } + }, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .matchParentSize() + .then( + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { // Android 12 + Modifier.blur(radius = 16.dp) + } else Modifier + ) + ) + Column( + modifier = Modifier + .background(Color.Black.copy(alpha = 0.5f)) + .fillMaxWidth() + .padding( + top = LARGE_SPACER.dp * 2, + bottom = LARGE_SPACER.dp + ) + ) { + Text( + text = artist.name, + modifier = Modifier.padding(horizontal = LARGE_SPACER.dp), + autoSize = TextAutoSize.StepBased( + minFontSize = 16.sp, + maxFontSize = 48.sp + ), + maxLines = 1, + fontFamily = FontFamily(Font(R.font.special_gothic_one_expanded)), + fontWeight = FontWeight.Bold + ) + artist.otherNames.takeIf { it.isNotEmpty() }?.let { + Summary( + text = "Also known as:\n" + it.joinToString(" / "), + modifier = Modifier.padding(horizontal = LARGE_SPACER.dp) + ) + } + LargeVerticalSpacer() + LazyRow( + contentPadding = PaddingValues(horizontal = LARGE_SPACER.dp), + horizontalArrangement = Arrangement.spacedBy(MEDIUM_SPACER.dp) + ) { + groupedSocials.forEach { (site, socials) -> + item { + SocialChip( + site = site, + socialProfiles = socials, + onClick = { uriHandler.openUri(it) } + ) + } + } + } + } + } + } + } +} + + +@Composable +private fun SocialChip( + site: SocialSite, + socialProfiles: List, + onClick: (String) -> Unit) +{ + var showDropdown by remember { mutableStateOf(false) } + val arrowRotation by animateFloatAsState(if (showDropdown) 180f else 0f) + val hasDropdownAvailable = remember { socialProfiles.size > 1 || site == SocialSite.OTHER } + + Box { + InputChip( + selected = showDropdown, + modifier = Modifier.heightIn(42.dp), + elevation = InputChipDefaults.inputChipElevation(elevation = 3.dp), + shapes = InputChipDefaults.shapes( + shape = InputChipDefaults.shapes().selectedShape, + selectedShape = InputChipDefaults.shapes().shape + ), + contentPadding = InputChipDefaults.contentPadding( + hasAvatar = false, + hasLeadingIcon = true, + hasTrailingIcon = hasDropdownAvailable + ) + PaddingValues(start = 4.dp), // We make the icons smaller so we're offsetting the difference + onClick = { + if (!hasDropdownAvailable) { + onClick(socialProfiles.first().url) + } else { + showDropdown = true + } + }, + label = { + Text( + text = site.label, + modifier = Modifier.padding( + start = TINY_SPACER.dp, + end = if (!hasDropdownAvailable) TINY_SPACER.dp else 0.dp + ) + ) + }, + colors = InputChipDefaults.inputChipColors( + containerColor = (site.color?.let { Color(it) } + ?: MaterialTheme.colorScheme.surfaceContainerHigh).copy(alpha = 0.75f), + labelColor = MaterialTheme.colorScheme.onSurface, + leadingIconColor = MaterialTheme.colorScheme.onSurface, + trailingIconColor = MaterialTheme.colorScheme.onSurface, + selectedLabelColor = MaterialTheme.colorScheme.onSurface, + selectedLeadingIconColor = MaterialTheme.colorScheme.onSurface, + selectedTrailingIconColor = MaterialTheme.colorScheme.onSurface, + selectedContainerColor = site.color?.let { Color(it) } + ?: MaterialTheme.colorScheme.surfaceContainerHigh + ), + border = null, + leadingIcon = { + Icon( + painter = painterResource(site.iconRes), + contentDescription = null, + modifier = Modifier + .size(22.dp) + ) + }, + trailingIcon = if (hasDropdownAvailable) { + { + Icon( + imageVector = Icons.Rounded.KeyboardArrowDown, + contentDescription = "View all links for ${site.label}", + modifier = Modifier + .size(20.dp) + .graphicsLayer { + rotationZ = arrowRotation + } + ) + } + } else null + ) + + BreadboardTheme { + DropdownMenu( + expanded = showDropdown, + onDismissRequest = { showDropdown = false }, + shape = MaterialTheme.shapes.medium, + containerColor = (site.color // Tint the dropdown slightly based on the site's colour + ?.let { Color(it).copy(alpha = 0.1f) } + ?: Color.Transparent) + .compositeOver(MaterialTheme.colorScheme.surfaceContainerHigh) + ) { + for (socialEntry in socialProfiles) { + DropdownMenuItem( + text = { Text(socialEntry.url) }, + leadingIcon = { + Icon( + painter = painterResource(site.iconRes), + contentDescription = null, + tint = site.color?.let { Color(it) } ?: LocalContentColor.current + ) + }, + onClick = { + onClick(socialEntry.url) + showDropdown = false + } + ) + } + } + } + } +} + + +private fun shareArtist(context: Context, artistTag: String, addFriendlyMessage: Boolean) { + val shareIntent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra( + Intent.EXTRA_TEXT, + "${if (addFriendlyMessage) "Check out this artist on Breadboard! " else ""}https://breadboard.moe/artist/$artistTag" + ) + } + context.startActivity(Intent.createChooser(shareIntent, null)) +} \ No newline at end of file diff --git a/app/src/main/java/moe/apex/breadboard/artist/ArtistSocial.kt b/app/src/main/java/moe/apex/breadboard/artist/ArtistSocial.kt new file mode 100644 index 00000000..7c77cd6e --- /dev/null +++ b/app/src/main/java/moe/apex/breadboard/artist/ArtistSocial.kt @@ -0,0 +1,63 @@ +package moe.apex.breadboard.artist + +import androidx.compose.runtime.Immutable +import moe.apex.breadboard.R +import androidx.core.net.toUri + + +@Immutable +data class ArtistSocial( + val url: String, + val isActive: Boolean +) + + +enum class SocialSite( + val label: String, + val color: Long?, + val iconRes: Int, + private val domains: List +) { + BILIBILI("Bilibili", 0xFF4C93FF, R.drawable.ic_bilibili, listOf("bilibili.com")), + BLUESKY("Bluesky", 0xFF1185FE, R.drawable.ic_bluesky, listOf("bsky.social", "bsky.app")), + DISCORD("Discord", 0xFF5865F2, R.drawable.ic_discord, listOf("discord.com", "discordapp.com", "discord.gg")), + FACEBOOK("Facebook", 0xFF1877F2, R.drawable.ic_facebook, listOf("facebook.com")), + FANBOX("pixivFANBOX", 0xFF958F5A, R.drawable.ic_pixiv, listOf("fanbox.cc")), // Fanbox is part of Pixiv and I can't find an appropriate dedicated icon. + INSTAGRAM("Instagram", 0xFFC13584, R.drawable.ic_instagram, listOf("instagram.com")), + LOFTER("Lofter", 0xFF459A94, R.drawable.ic_lofter, listOf("lofter.com")), + PATREON("Patreon", 0xFF000000, R.drawable.ic_patreon, listOf("patreon.com")), + PAWOO("Pawoo", 0xFF6364FF, R.drawable.ic_mastodon, listOf("pawoo.net")), + PIXIV("pixiv", 0xFF0096FA, R.drawable.ic_pixiv, listOf("pixiv.net")), + REDDIT("Reddit", 0xFFFF4500, R.drawable.ic_reddit, listOf("reddit.com", "old.reddit.com")), + SKEB("Skeb", 0xFF1E5E71, R.drawable.ic_skeb, listOf("skeb.jp")), + THREADS("Threads", 0xFF000000, R.drawable.ic_threads, listOf("threads.net", "threads.com")), + TIKTOK("TikTok", 0xFF000000, R.drawable.ic_tiktok, listOf("tiktok.com")), + TWITTER("Twitter", 0xFF1DA1F2, R.drawable.ic_twitter, listOf("twitter.com", "x.com")), + WEIBO("Weibo", 0xFFE6162D, R.drawable.ic_weibo, listOf("weibo.com", "weibo.cn")), + YOUTUBE("YouTube", 0xFFFF1A47, R.drawable.ic_youtube, listOf("youtube.com", "youtu.be")), + OTHER("Other links", null, R.drawable.ic_link, emptyList()); + + companion object { + fun fromUrl(url: String): SocialSite { + val uri = try { + url.toUri() + } catch (_: Exception) { + null + } + val host = uri?.host?.lowercase()?.removePrefix("www.") ?: "" + + if (host.isEmpty()) { + val lowercaseUrl = url.lowercase() + return entries.find { site -> + site.domains.any { domain -> lowercaseUrl.contains(domain) } + } ?: OTHER + } + + return entries.find { site -> + site.domains.any { domain -> + host == domain || host.endsWith(".$domain") + } + } ?: OTHER + } + } +} diff --git a/app/src/main/java/moe/apex/breadboard/artist/ArtistTag.kt b/app/src/main/java/moe/apex/breadboard/artist/ArtistTag.kt new file mode 100644 index 00000000..24d28605 --- /dev/null +++ b/app/src/main/java/moe/apex/breadboard/artist/ArtistTag.kt @@ -0,0 +1,10 @@ +package moe.apex.breadboard.artist + +import androidx.compose.runtime.Immutable + + +@Immutable +data class ArtistTag( + val name: String, + val postCount: Int +) 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 69b670e6..130d2b4b 100644 --- a/app/src/main/java/moe/apex/breadboard/detailview/ImageGrid.kt +++ b/app/src/main/java/moe/apex/breadboard/detailview/ImageGrid.kt @@ -5,6 +5,7 @@ import androidx.compose.animation.EnterTransition import androidx.compose.animation.fadeOut import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.ScrollableState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues @@ -67,66 +68,50 @@ private const val MIN_CELL_WIDTH = 120 private const val MAX_CELL_WIDTH = 144 +interface ImageGridHeaderScope { + fun item(content: @Composable () -> Unit) +} + + +class ImageGridHeaderScopeImpl : ImageGridHeaderScope { + val items = mutableListOf<@Composable () -> Unit>() + + override fun item(content: @Composable () -> Unit) { + items.add(content) + } +} + + @Composable -fun ImageGrid( +private fun ImageGridPullToRefreshContainer( modifier: Modifier = Modifier, - staggeredGridState: LazyStaggeredGridState = rememberLazyStaggeredGridState(), - uniformGridState: LazyGridState = rememberLazyGridState(), - images: List, - onImageClick: (Int, Image) -> Unit, - noImagesContent: @Composable () -> Unit = { NoImages() }, - contentPadding: PaddingValues = PaddingValues(0.dp), - filterComposable: (@Composable () -> Unit)? = null, - pullToRefreshController: PullToRefreshController? = null, - doneInitialLoad: Boolean = true, - onEndReached: (suspend () -> Unit)? = null + pullToRefreshController: PullToRefreshController?, + content: @Composable () -> Unit ) { - val prefs = LocalPreferences.current - - @Composable - fun Container(content: @Composable () -> Unit) { - if (pullToRefreshController != null) { - PullToRefreshBox( - modifier = modifier, - isRefreshing = pullToRefreshController.isRefreshing, - state = pullToRefreshController.state, - onRefresh = pullToRefreshController::refresh, - indicator = { - pullToRefreshController.indicator(this, pullToRefreshController) - }, - content = { content() } - ) - } else { - content() - } + if (pullToRefreshController != null) { + PullToRefreshBox( + modifier = modifier, + isRefreshing = pullToRefreshController.isRefreshing, + state = pullToRefreshController.state, + onRefresh = pullToRefreshController::refresh, + indicator = { + pullToRefreshController.indicator(this, pullToRefreshController) + }, + content = { content() } + ) + } else { + content() } +} - Container { - if (prefs.useStaggeredGrid) { - StaggeredImageGrid( - modifier = if (pullToRefreshController == null) modifier else Modifier, - gridState = staggeredGridState, - contentPadding = contentPadding, - filterComposable = filterComposable, - images = images, - noImagesContent = noImagesContent, - onImageClick = onImageClick, - onEndReached = onEndReached - ) - } else { - UniformImageGrid( - modifier = if (pullToRefreshController == null) modifier else Modifier, - gridState = uniformGridState, - contentPadding = contentPadding, - filterComposable = filterComposable, - images = images, - noImagesContent = noImagesContent, - onImageClick = onImageClick, - onEndReached = onEndReached - ) - } - } +@Composable +private fun InitialLoadingIndicator( + modifier: Modifier = Modifier, + doneInitialLoad: Boolean, + contentPadding: PaddingValues, + onInitialLoadCompleted: suspend () -> Unit +) { AnimatedVisibility ( visible = !doneInitialLoad, enter = EnterTransition.None, @@ -143,20 +128,162 @@ fun ImageGrid( LaunchedEffect(doneInitialLoad) { if (doneInitialLoad) { - staggeredGridState.requestScrollToItem(0) - uniformGridState.requestScrollToItem(0) + onInitialLoadCompleted() } } } } +@Composable +fun FlexibleImageGrid( + staggered: Boolean, + modifier: Modifier = Modifier, + images: List, + onImageClick: (Int, Image) -> Unit, + noImagesContent: @Composable () -> Unit = { NoImages() }, + contentPadding: PaddingValues = PaddingValues(0.dp), + headerItems: (ImageGridHeaderScope.() -> Unit)? = null, + pullToRefreshController: PullToRefreshController? = null, + doneInitialLoad: Boolean = true, + onEndReached: (suspend () -> Unit)? = null +) { + FlexibleImageGrid( + gridState = if (staggered) rememberLazyStaggeredGridState() else rememberLazyGridState(), + modifier = modifier, + images = images, + onImageClick = onImageClick, + noImagesContent = noImagesContent, + contentPadding = contentPadding, + headerItems = headerItems, + pullToRefreshController = pullToRefreshController, + doneInitialLoad = doneInitialLoad, + onEndReached = onEndReached + ) +} + + +@Composable +fun FlexibleImageGrid( + gridState: ScrollableState, + modifier: Modifier = Modifier, + images: List, + onImageClick: (Int, Image) -> Unit, + noImagesContent: @Composable () -> Unit = { NoImages() }, + contentPadding: PaddingValues = PaddingValues(0.dp), + headerItems: (ImageGridHeaderScope.() -> Unit)? = null, + pullToRefreshController: PullToRefreshController? = null, + doneInitialLoad: Boolean = true, + onEndReached: (suspend () -> Unit)? = null +) { + val scope = ImageGridHeaderScopeImpl() + if (headerItems != null) { + scope.headerItems() + } + + when (gridState) { + is LazyStaggeredGridState -> { + ImageGridPullToRefreshContainer( + modifier = modifier, + pullToRefreshController = pullToRefreshController + ) { + StaggeredImageGrid( + modifier = if (pullToRefreshController == null) modifier else Modifier, + gridState = gridState, + contentPadding = contentPadding, + headerItems = scope.items, + images = images, + noImagesContent = noImagesContent, + onImageClick = onImageClick, + onEndReached = onEndReached + ) + } + + InitialLoadingIndicator( + modifier = modifier, + doneInitialLoad = doneInitialLoad, + contentPadding = contentPadding, + onInitialLoadCompleted = { + gridState.requestScrollToItem(0) + } + ) + } + + is LazyGridState -> { + ImageGridPullToRefreshContainer( + modifier = modifier, + pullToRefreshController = pullToRefreshController + ) { + UniformImageGrid( + modifier = if (pullToRefreshController == null) modifier else Modifier, + gridState = gridState, + contentPadding = contentPadding, + headerItems = scope.items, + images = images, + noImagesContent = noImagesContent, + onImageClick = onImageClick, + onEndReached = onEndReached + ) + } + + InitialLoadingIndicator( + modifier = modifier, + doneInitialLoad = doneInitialLoad, + contentPadding = contentPadding, + onInitialLoadCompleted = { + gridState.scrollToItem(0) + } + ) + } + + else -> { + throw IllegalArgumentException("gridState must be either LazyStaggeredGridState or LazyGridState") + } + } +} + + +@Deprecated( + message = "Use FlexibleImageGrid, which allows for passing in multiple header items, instead.", + replaceWith = ReplaceWith("FlexibleImageGrid") +) +@Composable +fun ImageGrid( + modifier: Modifier = Modifier, + staggeredGridState: LazyStaggeredGridState = rememberLazyStaggeredGridState(), + uniformGridState: LazyGridState = rememberLazyGridState(), + images: List, + onImageClick: (Int, Image) -> Unit, + noImagesContent: @Composable () -> Unit = { NoImages() }, + contentPadding: PaddingValues = PaddingValues(0.dp), + filterComposable: (@Composable () -> Unit)? = null, + pullToRefreshController: PullToRefreshController? = null, + doneInitialLoad: Boolean = true, + onEndReached: (suspend () -> Unit)? = null +) { + val prefs = LocalPreferences.current + + FlexibleImageGrid( + gridState = if (prefs.useStaggeredGrid) staggeredGridState else uniformGridState, + modifier = modifier, + images = images, + onImageClick = onImageClick, + noImagesContent = noImagesContent, + contentPadding = contentPadding, + headerItems = filterComposable?.let { { item { it() }} }, + pullToRefreshController = pullToRefreshController, + doneInitialLoad = doneInitialLoad, + onEndReached = onEndReached + ) +} + + @Composable private fun StaggeredImageGrid( modifier: Modifier = Modifier, gridState: LazyStaggeredGridState = rememberLazyStaggeredGridState(), contentPadding: PaddingValues, - filterComposable: (@Composable () -> Unit)? = null, + headerItems: (List<@Composable () -> Unit>)? = null, images: List, noImagesContent: @Composable () -> Unit, onImageClick: (Int, Image) -> Unit, @@ -172,9 +299,9 @@ private fun StaggeredImageGrid( horizontalArrangement = Arrangement.spacedBy(SMALL_SPACER.dp), verticalItemSpacing = SMALL_SPACER.dp ) { - filterComposable?.let { - item(key = "ratings-filter", span = StaggeredGridItemSpan.FullLine ) { - it() + headerItems?.forEachIndexed { index, element -> + item(key = "header-$index", span = StaggeredGridItemSpan.FullLine) { + element() } } @@ -210,7 +337,7 @@ private fun UniformImageGrid( modifier: Modifier = Modifier, gridState: LazyGridState = rememberLazyGridState(), contentPadding: PaddingValues, - filterComposable: (@Composable () -> Unit)? = null, + headerItems: List<@Composable () -> Unit>? = null, images: List, noImagesContent: @Composable () -> Unit, onImageClick: (Int, Image) -> Unit, @@ -226,9 +353,9 @@ private fun UniformImageGrid( horizontalArrangement = Arrangement.spacedBy(SMALL_SPACER.dp), verticalArrangement = Arrangement.spacedBy(SMALL_SPACER.dp) ) { - filterComposable?.let { - item(key = "ratings-filter", span = { GridItemSpan(maxLineSpan) }) { - it() + headerItems?.forEachIndexed { index, element -> + item(key = "header-$index", span = { GridItemSpan(maxLineSpan) }) { + element() } } @@ -302,7 +429,7 @@ private fun LazyGridItemScope.ImagePreviewContainer( @Composable -private fun ImagePreview( +fun ImagePreview( modifier: Modifier = Modifier, image: Image, index: Int, 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 d844ba9b..fbd0a74d 100644 --- a/app/src/main/java/moe/apex/breadboard/image/ImageBoard.kt +++ b/app/src/main/java/moe/apex/breadboard/image/ImageBoard.kt @@ -4,6 +4,9 @@ import android.util.Log import kotlinx.coroutines.delay import kotlinx.serialization.Serializable import moe.apex.breadboard.RequestUtil +import moe.apex.breadboard.artist.Artist +import moe.apex.breadboard.artist.ArtistSocial +import moe.apex.breadboard.artist.ArtistTag import moe.apex.breadboard.preferences.ImageSource import moe.apex.breadboard.tag.TagCategory import moe.apex.breadboard.tag.TagGroup @@ -419,6 +422,9 @@ object Danbooru : ImageBoard { override val apiKeyRequirement = ImageBoardRequirement.RECOMMENDED override val localFilterType = ImageBoardRequirement.RECOMMENDED + private val artistSearchUrl = "${baseUrl}artists.json?only=name,urls,other_names,tag&search[any_name_matches]=%s" + private val sfwUrl = "https://safebooru.donmai.us/posts.json?tags=%s&limit=20" + override fun parseImage(e: JSONObject): Image? { if (e.isNull("md5")) return null @@ -496,6 +502,57 @@ object Danbooru : ImageBoard { return img?.metadata } + suspend fun getArtist(artistTag: String): Artist? { + val url = artistSearchUrl.format(artistTag) + val body = RequestUtil.get(url) + val json = JSONArray(body) + + if (json.length() == 0){ + return null + } + + val primaryArtist = json.getJSONObject(0) + return Artist( + name = primaryArtist.getString("name"), + otherNames = primaryArtist.optJSONArray("other_names")?.let { otherNamesArray -> + (0 until otherNamesArray.length()).mapNotNull { index -> + otherNamesArray.optString(index).takeIf { it.isNotBlank() } + } + } ?: emptyList(), + socialUrls = primaryArtist.optJSONArray("urls")?.let { urlArray -> + (0 until urlArray.length()).mapNotNull { index -> + val urlEntry = urlArray.optJSONObject(index) + ArtistSocial( + url = urlEntry.optString("url").takeUnless { it.isBlank() } ?: return@mapNotNull null, + isActive = urlEntry.optBoolean("is_active", false) + ) + } + } ?: emptyList(), + tag = primaryArtist.getJSONObject("tag").let { tagObj -> + ArtistTag( + name = tagObj.getString("name"), + postCount = tagObj.getInt("post_count") + ) + } + ) + } + + suspend fun getMostPopularSfwPosts(artistTag: String): List { + val url = sfwUrl.format("$artistTag order:favcount") + val body = RequestUtil.get(url) + if (body.isEmpty()) return emptyList() + + val json = JSONArray(body) + val subjects = mutableListOf() + + for (i in 0 until json.length()) { + val e = json.getJSONObject(i) + parseImage(e)?.let { subjects.add(it) } + } + + return subjects.toList() + } + override fun getRatingFromString(rating: String): ImageRating { return when (rating) { 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 26cddca1..1bed6ce2 100644 --- a/app/src/main/java/moe/apex/breadboard/largeimageview/InfoSheet.kt +++ b/app/src/main/java/moe/apex/breadboard/largeimageview/InfoSheet.kt @@ -72,6 +72,7 @@ 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.ArtistProfile import moe.apex.breadboard.navigation.ImageView import moe.apex.breadboard.navigation.Results import moe.apex.breadboard.preferences.ImageSource @@ -156,14 +157,21 @@ fun InfoSheet(navController: NavController, image: Image, onDismissRequest: () - } var selectedTag: String? by remember { mutableStateOf(null) } + var selectedTagCategory: TagCategory? by remember { mutableStateOf(null) } - fun startTagSearch(tag: String) { + fun startTagSearch(tag: String, category: TagCategory) { hideAndThen { - /* Don't do new searches inside the DeepLinkActivity. We should only - ever do them inside the main one. */ + /* Don't do new interactions inside the DeepLinkActivity. + We should only ever do them inside the main one. */ if (context is DeepLinkActivity) { - val intent = createSearchIntent(context, image.imageSource, tag) + val intent = if (category == TagCategory.ARTIST) { + createArtistIntent(context, tag) + } else { + createSearchIntent(context, image.imageSource, tag) + } context.startActivity(intent) + } else if (category == TagCategory.ARTIST) { + navController.navigate(ArtistProfile(tag)) } else { navController.navigate(Results(image.imageSource, listOf(tag))) } @@ -207,14 +215,16 @@ fun InfoSheet(navController: NavController, image: Image, onDismissRequest: () - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { ButtonListItem( - label = "Search", + label = if (selectedTagCategory == TagCategory.ARTIST) "View profile" else "Search", icon = Icons.Rounded.Search, modifier = Modifier.fillMaxWidth(), position = ListItemPosition.TOP ) { val searchTag = selectedTag!! + val category = selectedTagCategory!! selectedTag = null - startTagSearch(searchTag) + selectedTagCategory = null + startTagSearch(searchTag, category) } ButtonListItem( label = "Copy to clipboard", @@ -281,7 +291,10 @@ fun InfoSheet(navController: NavController, image: Image, onDismissRequest: () - val onBrowserLinkClick = { url: String -> // Bypass Breadboard's handling of file URLs like for Yande.re launchInWebBrowser(context, url) } - val onTagLongClick = { tag: String -> selectedTag = tag } + val onTagLongClick = { tag: String, category: TagCategory -> + selectedTag = tag + selectedTagCategory = category + } val onViewParentClick = { id: String -> hideAndThen { navController.navigate(ImageView(image.imageSource, id, false)) @@ -295,8 +308,8 @@ fun InfoSheet(navController: NavController, image: Image, onDismissRequest: () - onBrowserLinkClick = onBrowserLinkClick, onCopyClick = onCopyClick, onViewParentClick = onViewParentClick, - onViewRelatedClick = { startTagSearch("parent:$it") }, - onTagClick = ::startTagSearch, + onViewRelatedClick = { startTagSearch("parent:$it", TagCategory.GENERAL) }, + onTagClick = { tag, category -> startTagSearch(tag, category) }, onTagLongClick = onTagLongClick ) } else { @@ -312,8 +325,8 @@ fun InfoSheet(navController: NavController, image: Image, onDismissRequest: () - onBrowserLinkClick = onBrowserLinkClick, onCopyClick = onCopyClick, onViewParentClick = onViewParentClick, - onViewRelatedClick = { startTagSearch("parent:$it") }, - onTagClick = ::startTagSearch, + onViewRelatedClick = { startTagSearch("parent:$it", TagCategory.GENERAL) }, + onTagClick = { tag, category -> startTagSearch(tag, category) }, onTagLongClick = onTagLongClick ) @@ -322,7 +335,7 @@ fun InfoSheet(navController: NavController, image: Image, onDismissRequest: () - onLinkClick = onLinkClick, onBrowserLinkClick = onBrowserLinkClick, onCopyClick = onCopyClick, - onTagClick = ::startTagSearch, + onTagClick = { tag, category -> startTagSearch(tag, category) }, onTagLongClick = onTagLongClick ) } @@ -413,8 +426,8 @@ private fun InfoTabContent( onCopyClick: (String) -> Unit, onViewParentClick: (String) -> Unit, onViewRelatedClick: (String) -> Unit, - onTagClick: (String) -> Unit, - onTagLongClick: (String) -> Unit + onTagClick: (String, TagCategory) -> Unit, + onTagLongClick: (String, TagCategory) -> Unit ) { SplitInfoSheetLazyColumn { infoContentItems( @@ -437,8 +450,8 @@ private fun ImageboardDataTabContent( onLinkClick: (String) -> Unit, onBrowserLinkClick: (String) -> Unit, onCopyClick: (String) -> Unit, - onTagClick: (String) -> Unit, - onTagLongClick: (String) -> Unit + onTagClick: (String, TagCategory) -> Unit, + onTagLongClick: (String, TagCategory) -> Unit ) { SplitInfoSheetLazyColumn { imageboardDataContentItems( @@ -461,8 +474,8 @@ private fun UnifiedInfoContent( onCopyClick: (String) -> Unit, onViewParentClick: (String) -> Unit, onViewRelatedClick: (String) -> Unit, - onTagClick: (String) -> Unit, - onTagLongClick: (String) -> Unit + onTagClick: (String, TagCategory) -> Unit, + onTagLongClick: (String, TagCategory) -> Unit ) { LazyColumn( modifier = Modifier.fillMaxWidth(), @@ -515,8 +528,8 @@ private fun LazyListScope.infoContentItems( onCopyClick: (String) -> Unit, onViewParentClick: (String) -> Unit, onViewRelatedClick: (String) -> Unit, - onTagClick: (String) -> Unit, - onTagLongClick: (String) -> Unit, + onTagClick: (String, TagCategory) -> Unit, + onTagLongClick: (String, TagCategory) -> Unit, unified: Boolean = false ) { if (image.isAiGenerated) { @@ -573,14 +586,12 @@ private fun LazyListScope.infoContentItems( /* In unified mode, display file URL with the other URLs. In split mode, it's displayed on the other tab. */ if (unified) { - image.highestQualityFormatUrl.let { - UrlItem( - label = "File URL", - url = it, - onLinkClick = onBrowserLinkClick, - onCopyClick = onCopyClick - ) - } + UrlItem( + label = "File URL", + url = image.highestQualityFormatUrl, + onLinkClick = onBrowserLinkClick, + onCopyClick = onCopyClick + ) } image.metadata.parentId?.let { @@ -625,8 +636,8 @@ private fun LazyListScope.imageboardDataContentItems( onLinkClick: (String) -> Unit, // Not currently used but keeping for consistency and possible future use onBrowserLinkClick: (String) -> Unit, onCopyClick: (String) -> Unit, - onTagClick: (String) -> Unit, - onTagLongClick: (String) -> Unit, + onTagClick: (String, TagCategory) -> Unit, + onTagLongClick: (String, TagCategory) -> Unit, unified: Boolean = false ) { if (!unified) { @@ -692,8 +703,8 @@ private fun ExpressiveGroupScope.UrlItem( private fun ExpressiveGroupScope.mainTagsItems( image: Image, - onTagClick: (String) -> Unit, - onTagLongClick: (String) -> Unit + onTagClick: (String, TagCategory) -> Unit, + onTagLongClick: (String, TagCategory) -> Unit ) { image.metadata!!.artists.takeIf { it.isNotEmpty() }?.let { item { @@ -739,8 +750,8 @@ private fun CopyIcon(itemType: String, onClick: () -> Unit) { private fun TagsContainer( category: TagCategory, tags: List, - onTagClick: (String) -> Unit, - onTagLongClick: (String) -> Unit + onTagClick: (String, TagCategory) -> Unit, + onTagLongClick: (String, TagCategory) -> Unit ) { val maxLines = 11 // 10 but apparently the expand indicator is included in this figure so 11 val prefs = LocalPreferences.current @@ -791,8 +802,8 @@ private fun TagsContainer( CombinedClickableFilterChip( label = { Text(text = tag, maxLines = 1) }, warning = tag in prefs.blockedTags, - onClick = { onTagClick(tag) }, - onLongClick = { onTagLongClick(tag) } + onClick = { onTagClick(tag, category) }, + onLongClick = { onTagLongClick(tag, category) } ) } } @@ -820,19 +831,26 @@ private fun ExpandCollapseRow( private fun createSearchIntent(context: Context, imageSource: ImageSource, query: String): Intent { - return createSearchIntent(context, imageSource, listOf(query)) + return createMainActivityIntent(context, "search") + .putExtra("source", imageSource.name) + .putExtra("query", listOf(query).toTypedArray()) +} + + +private fun createArtistIntent(context: Context, artistTag: String): Intent { + return createMainActivityIntent(context, "artist") + .putExtra("artist", artistTag) } -private fun createSearchIntent(context: Context, imageSource: ImageSource, queries: List): Intent { +private fun createMainActivityIntent(context: Context, destination: String): Intent { val intent = Intent(Intent.ACTION_VIEW) - intent.putExtra("source", imageSource.name) intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK - intent.putExtra("query", queries.toTypedArray()) intent.component = ComponentName( context, MainActivity::class.java ) + intent.putExtra("destination", destination) return intent } diff --git a/app/src/main/java/moe/apex/breadboard/navigation/Destinations.kt b/app/src/main/java/moe/apex/breadboard/navigation/Destinations.kt index ee44485b..128b4a5e 100644 --- a/app/src/main/java/moe/apex/breadboard/navigation/Destinations.kt +++ b/app/src/main/java/moe/apex/breadboard/navigation/Destinations.kt @@ -114,6 +114,27 @@ object RecommendationsSettings @Serializable object IgnoredTagsSettings +@Serializable +data class ArtistProfile( + val artistTag: String +) { + companion object { + fun fromUri(uri: Uri): ArtistProfile? { + val host = uri.host ?: return null + val path = uri.pathSegments ?: return null + + if (host == "breadboard.moe" && path.firstOrNull() == "artist") { + val tag = path.getOrNull(1)?.takeIf { it.isNotEmpty() } + if (tag != null) { + return ArtistProfile(tag) + } + } + return null + } + } +} + + fun NavDestination?.routeIs(routes: Collection>): Boolean { return routeIs(*routes.toTypedArray()) } diff --git a/app/src/main/java/moe/apex/breadboard/navigation/Navigation.kt b/app/src/main/java/moe/apex/breadboard/navigation/Navigation.kt index 039340de..f4e25735 100644 --- a/app/src/main/java/moe/apex/breadboard/navigation/Navigation.kt +++ b/app/src/main/java/moe/apex/breadboard/navigation/Navigation.kt @@ -43,6 +43,7 @@ import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.toRoute import moe.apex.breadboard.search.SearchScreen import moe.apex.breadboard.R +import moe.apex.breadboard.artist.ArtistProfileScreen import moe.apex.breadboard.detailview.SearchResults import moe.apex.breadboard.favourites.FavouritesPage import moe.apex.breadboard.home.HomeScreen @@ -80,10 +81,10 @@ fun Navigation(navController: NavHostController, startDestination: Any = Search) val popExitTransition = slideOutHorizontally(tween(easing = easing)) { slideDistance } + fadeOut( tween(easing = easing)) val popEnterTransition = slideInHorizontally(tween(easing = easing)) { -slideDistance } + fadeIn(tween(easing = easing)) - val searchScreens = listOf(Search::class, Results::class) + val searchScreens = listOf(Search::class, Results::class, ArtistProfile::class) val settingsScreens = listOf(Settings::class, BlockedTagsSettings::class, AboutSettings::class, LibrariesSettings::class, ExperimentalSettings::class, RecommendationsSettings::class, IgnoredTagsSettings::class) val topLevelScreens = listOf(Home::class, Search::class, Favourites::class) + settingsScreens - val slideTransitionScreens = listOf(Results::class, ImageView::class, *settingsScreens.filter { it != Settings::class }.toTypedArray()) + val slideTransitionScreens = listOf(Results::class, ImageView::class, ArtistProfile::class, *settingsScreens.filter { it != Settings::class }.toTypedArray()) /* Some screens have the ability to hide the bottom bar, so we need to ensure it appears again when navigating to a different screen. */ @@ -225,6 +226,10 @@ fun Navigation(navController: NavHostController, startDestination: Any = Search) composable { ExperimentalScreen(navController) } composable { RecommendationsSettingsScreen(navController) } composable { IgnoredTagsScreen(navController) } + composable { + val args = it.toRoute() + ArtistProfileScreen(args.artistTag, navController = navController) + } } } } 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 afd67a32..ebbb5900 100644 --- a/app/src/main/java/moe/apex/breadboard/util/Ui.kt +++ b/app/src/main/java/moe/apex/breadboard/util/Ui.kt @@ -196,12 +196,30 @@ fun animateBottomCornerSizeForPosition(position: ListItemPosition): Dp { } +/** Default drop-in navigation icon to be used in top bars. + + Includes padding by default to fit Breadboard style. For more control, use the overload with + a `modifier` parameter. **/ @Composable -private fun NavigationIcon(navController: NavController? = null) { +fun NavigationIcon(navController: NavController?) { + NavigationIcon( + modifier = Modifier.padding(horizontal = SMALL_SPACER.dp), + navController = navController + ) +} + + +/** A lower level navigation icon that accepts a [modifier] to allow for more flexible behaviour and + appearance. */ +@Composable +fun NavigationIcon( + modifier: Modifier, + navController: NavController? = null +) { val context = LocalContext.current if (navController != null) { FilledIconButton( - modifier = Modifier.padding(horizontal = SMALL_SPACER.dp), + modifier = modifier, colors = IconButtonDefaults.filledIconButtonColors( containerColor = IconButtonDefaults.filledIconButtonColors().disabledContainerColor.copy(alpha = 0.065f), contentColor = MaterialTheme.colorScheme.onSurfaceVariant diff --git a/app/src/main/java/moe/apex/breadboard/viewmodel/ArtistProfileViewModel.kt b/app/src/main/java/moe/apex/breadboard/viewmodel/ArtistProfileViewModel.kt new file mode 100644 index 00000000..e7a2cc5f --- /dev/null +++ b/app/src/main/java/moe/apex/breadboard/viewmodel/ArtistProfileViewModel.kt @@ -0,0 +1,43 @@ +package moe.apex.breadboard.viewmodel + +import android.util.Log +import androidx.lifecycle.ViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import moe.apex.breadboard.artist.Artist +import moe.apex.breadboard.image.Danbooru +import moe.apex.breadboard.image.Image + + +class ArtistProfileViewModel(private val artistTag: String): ViewModel() { + private val _artistProfile = MutableStateFlow(null) + val artistProfile = _artistProfile.asStateFlow() + private val _isInitialised = MutableStateFlow(false) + val isInitialised = _isInitialised.asStateFlow() + + private val _images = MutableStateFlow>(emptyList()) + val images = _images.asStateFlow() + + + suspend fun loadArtistProfile() { + if (_isInitialised.value) { + return + } + + Log.i("ArtistProfileViewModel", "Loading artist profile for $artistTag") + + _artistProfile.value = Danbooru.getArtist(artistTag) + _images.value = Danbooru.getMostPopularSfwPosts(artistTag) + + _isInitialised.value = 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 + } + } +} diff --git a/app/src/main/res/drawable/ic_bilibili.xml b/app/src/main/res/drawable/ic_bilibili.xml new file mode 100644 index 00000000..da7cda2c --- /dev/null +++ b/app/src/main/res/drawable/ic_bilibili.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_bluesky.xml b/app/src/main/res/drawable/ic_bluesky.xml new file mode 100644 index 00000000..e5db209f --- /dev/null +++ b/app/src/main/res/drawable/ic_bluesky.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_discord.xml b/app/src/main/res/drawable/ic_discord.xml new file mode 100644 index 00000000..907ab14a --- /dev/null +++ b/app/src/main/res/drawable/ic_discord.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_facebook.xml b/app/src/main/res/drawable/ic_facebook.xml new file mode 100644 index 00000000..187c156b --- /dev/null +++ b/app/src/main/res/drawable/ic_facebook.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_instagram.xml b/app/src/main/res/drawable/ic_instagram.xml new file mode 100644 index 00000000..e32818f0 --- /dev/null +++ b/app/src/main/res/drawable/ic_instagram.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_link.xml b/app/src/main/res/drawable/ic_link.xml new file mode 100644 index 00000000..6b0d2cbf --- /dev/null +++ b/app/src/main/res/drawable/ic_link.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_lofter.xml b/app/src/main/res/drawable/ic_lofter.xml new file mode 100644 index 00000000..0b9fe74b --- /dev/null +++ b/app/src/main/res/drawable/ic_lofter.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_mastodon.xml b/app/src/main/res/drawable/ic_mastodon.xml new file mode 100644 index 00000000..00226cd4 --- /dev/null +++ b/app/src/main/res/drawable/ic_mastodon.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_patreon.xml b/app/src/main/res/drawable/ic_patreon.xml new file mode 100644 index 00000000..c2c6cec0 --- /dev/null +++ b/app/src/main/res/drawable/ic_patreon.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_pixiv.xml b/app/src/main/res/drawable/ic_pixiv.xml new file mode 100644 index 00000000..7e050334 --- /dev/null +++ b/app/src/main/res/drawable/ic_pixiv.xml @@ -0,0 +1,25 @@ + + + + diff --git a/app/src/main/res/drawable/ic_reddit.xml b/app/src/main/res/drawable/ic_reddit.xml new file mode 100644 index 00000000..baa713c6 --- /dev/null +++ b/app/src/main/res/drawable/ic_reddit.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_skeb.xml b/app/src/main/res/drawable/ic_skeb.xml new file mode 100644 index 00000000..bdae3889 --- /dev/null +++ b/app/src/main/res/drawable/ic_skeb.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_threads.xml b/app/src/main/res/drawable/ic_threads.xml new file mode 100644 index 00000000..3516b6fe --- /dev/null +++ b/app/src/main/res/drawable/ic_threads.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_tiktok.xml b/app/src/main/res/drawable/ic_tiktok.xml new file mode 100644 index 00000000..8c1f368e --- /dev/null +++ b/app/src/main/res/drawable/ic_tiktok.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_twitter.xml b/app/src/main/res/drawable/ic_twitter.xml new file mode 100644 index 00000000..0ea9b66b --- /dev/null +++ b/app/src/main/res/drawable/ic_twitter.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_weibo.xml b/app/src/main/res/drawable/ic_weibo.xml new file mode 100644 index 00000000..f2442a76 --- /dev/null +++ b/app/src/main/res/drawable/ic_weibo.xml @@ -0,0 +1,25 @@ + + + + diff --git a/app/src/main/res/drawable/ic_youtube.xml b/app/src/main/res/drawable/ic_youtube.xml new file mode 100644 index 00000000..c03d92e3 --- /dev/null +++ b/app/src/main/res/drawable/ic_youtube.xml @@ -0,0 +1,20 @@ + + + + + + diff --git a/app/src/main/res/font/special_gothic_one_expanded.ttf b/app/src/main/res/font/special_gothic_one_expanded.ttf new file mode 100644 index 0000000000000000000000000000000000000000..1da7c1904da1665011693bb2d4fa330a4cceaf4f GIT binary patch literal 101024 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 Date: Wed, 1 Jul 2026 00:49:20 +0100 Subject: [PATCH 02/59] Wide font for headers to match modern branding --- app/src/main/java/moe/apex/breadboard/ui/theme/Type.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/moe/apex/breadboard/ui/theme/Type.kt b/app/src/main/java/moe/apex/breadboard/ui/theme/Type.kt index 20e78ef6..0c31fdc7 100644 --- a/app/src/main/java/moe/apex/breadboard/ui/theme/Type.kt +++ b/app/src/main/java/moe/apex/breadboard/ui/theme/Type.kt @@ -38,8 +38,8 @@ val Typography = Typography( fontSize = 15.sp, ), headlineMedium = Typography().headlineMedium.copy( - fontFamily = FontFamily(Font(R.font.kumbh)), - fontWeight = FontWeight.Bold, + fontFamily = FontFamily(Font(R.font.special_gothic_one_expanded)), + fontWeight = FontWeight.Normal, fontSize = 30.sp ), headlineSmall = Typography().headlineSmall.copy( @@ -47,8 +47,8 @@ val Typography = Typography( fontWeight = FontWeight.Bold ), titleLarge = Typography().titleLarge.copy( - fontFamily = FontFamily(Font(R.font.kumbh)), - fontWeight = FontWeight.Bold, + fontFamily = FontFamily(Font(R.font.special_gothic_one_expanded)), + fontWeight = FontWeight.Normal, ) ) From de5dd3c22dbdd62e1aa728115b20cca124d483df Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Thu, 2 Jul 2026 03:21:30 +0100 Subject: [PATCH 03/59] Implement following artists and following tab. Very early. --- .../java/moe/apex/breadboard/RequestUtil.kt | 10 +- .../breadboard/artist/ArtistProfileScreen.kt | 18 +- .../apex/breadboard/detailview/ImageGrid.kt | 159 +++++---- .../breadboard/detailview/SearchResults.kt | 3 +- .../moe/apex/breadboard/home/HomeScreen.kt | 317 +++++++++++++----- .../moe/apex/breadboard/image/ImageBoard.kt | 60 +++- .../preferences/BlockedTagsScreen.kt | 4 +- .../moe/apex/breadboard/preferences/Pref.kt | 13 +- .../breadboard/preferences/Preferences.kt | 4 +- .../RecommendationsSettingsScreen.kt | 12 +- .../apex/breadboard/util/FollowingProvider.kt | 97 ++++++ .../moe/apex/breadboard/util/Modifiers.kt | 22 +- .../main/java/moe/apex/breadboard/util/Ui.kt | 29 +- .../viewmodel/ArtistProfileViewModel.kt | 3 +- .../apex/breadboard/viewmodel/ViewModel.kt | 13 + 15 files changed, 566 insertions(+), 198 deletions(-) create mode 100644 app/src/main/java/moe/apex/breadboard/util/FollowingProvider.kt diff --git a/app/src/main/java/moe/apex/breadboard/RequestUtil.kt b/app/src/main/java/moe/apex/breadboard/RequestUtil.kt index 3d80ecf5..42eea26f 100644 --- a/app/src/main/java/moe/apex/breadboard/RequestUtil.kt +++ b/app/src/main/java/moe/apex/breadboard/RequestUtil.kt @@ -17,7 +17,7 @@ object RequestUtil { suspend fun get(url: String, apply: Request.Builder.() -> Unit = {}): String = withContext(Dispatchers.IO) { val req = Request.Builder().url(url).apply(apply).get().build() - + suspendCancellableCoroutine { continuation -> val call = client.newCall(req) @@ -35,8 +35,12 @@ object RequestUtil { override fun onResponse(call: Call, response: Response) { if (continuation.isActive) { response.use { - val body = it.body.string() - continuation.resume(body) + if (it.isSuccessful) { + val body = it.body.string() + continuation.resume(body) + } else { + onFailure(call, IOException("HTTP error code: ${it.code}")) + } } } } diff --git a/app/src/main/java/moe/apex/breadboard/artist/ArtistProfileScreen.kt b/app/src/main/java/moe/apex/breadboard/artist/ArtistProfileScreen.kt index 21d87630..2fe7c80d 100644 --- a/app/src/main/java/moe/apex/breadboard/artist/ArtistProfileScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/artist/ArtistProfileScreen.kt @@ -250,7 +250,7 @@ private fun ArtistToolbar(artistTag: String, ) { modifier = Modifier.padding(SMALL_SPACER.dp), horizontalArrangement = Arrangement.spacedBy(SMALL_SPACER.dp) ) { - var isFollowing by remember { mutableStateOf(false) } // TODO Implement properly + val isFollowing = artistTag in prefs.followedTags ButtonGroup( overflowIndicator = { }, @@ -267,7 +267,21 @@ private fun ArtistToolbar(artistTag: String, ) { .height(48.dp) .weight(3f) .animateWidth(interactionSource), - onCheckedChange = { isFollowing = it }, + onCheckedChange = { + scope.launch { + if (it) { + preferencesRepository.addToSet( + PreferenceKeys.FOLLOWED_TAGS, + artistTag + ) + } else { + preferencesRepository.removeFromSet( + PreferenceKeys.FOLLOWED_TAGS, + artistTag + ) + } + } + }, colors = ToggleButtonDefaults.toggleButtonColors( containerColor = MaterialTheme.colorScheme.primary, contentColor = MaterialTheme.colorScheme.onPrimary, 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 130d2b4b..7b0a1f4d 100644 --- a/app/src/main/java/moe/apex/breadboard/detailview/ImageGrid.kt +++ b/app/src/main/java/moe/apex/breadboard/detailview/ImageGrid.kt @@ -32,6 +32,8 @@ 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.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.pulltorefresh.PullToRefreshBox @@ -82,6 +84,34 @@ class ImageGridHeaderScopeImpl : ImageGridHeaderScope { } +object FlexibleImageGridDefaults { + @Composable + fun WideLoadingIndicator() = WideLinearWavyProgressIndicator(modifier = Modifier.fillMaxWidth()) + + + @OptIn(ExperimentalMaterial3ExpressiveApi::class) + @Composable + fun RoundLoadingIndicator() { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + LoadingIndicator() + } + } + + + @Composable + fun NoImages() { + Text( + text = "No images :(", + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } +} + + @Composable private fun ImageGridPullToRefreshContainer( modifier: Modifier = Modifier, @@ -97,6 +127,7 @@ private fun ImageGridPullToRefreshContainer( indicator = { pullToRefreshController.indicator(this, pullToRefreshController) }, + enabled = pullToRefreshController.enabled, content = { content() } ) } else { @@ -106,24 +137,25 @@ private fun ImageGridPullToRefreshContainer( @Composable -private fun InitialLoadingIndicator( +private fun LoadingIndicatorContainer( modifier: Modifier = Modifier, doneInitialLoad: Boolean, contentPadding: PaddingValues, - onInitialLoadCompleted: suspend () -> Unit + onInitialLoadCompleted: suspend () -> Unit, + content: @Composable () -> Unit ) { - AnimatedVisibility ( + AnimatedVisibility( visible = !doneInitialLoad, enter = EnterTransition.None, exit = fadeOut(), ) { Box( - Modifier + modifier .fillMaxSize() .padding(contentPadding) .background(MaterialTheme.colorScheme.background) ) { - WideLinearWavyProgressIndicator(modifier = modifier.fillMaxWidth()) + content() } LaunchedEffect(doneInitialLoad) { @@ -139,18 +171,21 @@ private fun InitialLoadingIndicator( fun FlexibleImageGrid( staggered: Boolean, modifier: Modifier = Modifier, + userScrollEnabled: Boolean = true, images: List, onImageClick: (Int, Image) -> Unit, - noImagesContent: @Composable () -> Unit = { NoImages() }, + noImagesContent: @Composable () -> Unit = FlexibleImageGridDefaults::NoImages, contentPadding: PaddingValues = PaddingValues(0.dp), headerItems: (ImageGridHeaderScope.() -> Unit)? = null, pullToRefreshController: PullToRefreshController? = null, doneInitialLoad: Boolean = true, + loadingIndicator: @Composable () -> Unit = FlexibleImageGridDefaults::WideLoadingIndicator, onEndReached: (suspend () -> Unit)? = null ) { FlexibleImageGrid( gridState = if (staggered) rememberLazyStaggeredGridState() else rememberLazyGridState(), modifier = modifier, + userScrollEnabled = userScrollEnabled, images = images, onImageClick = onImageClick, noImagesContent = noImagesContent, @@ -158,6 +193,7 @@ fun FlexibleImageGrid( headerItems = headerItems, pullToRefreshController = pullToRefreshController, doneInitialLoad = doneInitialLoad, + loadingIndicator = loadingIndicator, onEndReached = onEndReached ) } @@ -167,13 +203,15 @@ fun FlexibleImageGrid( fun FlexibleImageGrid( gridState: ScrollableState, modifier: Modifier = Modifier, + userScrollEnabled: Boolean = true, images: List, onImageClick: (Int, Image) -> Unit, - noImagesContent: @Composable () -> Unit = { NoImages() }, + noImagesContent: @Composable () -> Unit = FlexibleImageGridDefaults::NoImages, contentPadding: PaddingValues = PaddingValues(0.dp), headerItems: (ImageGridHeaderScope.() -> Unit)? = null, pullToRefreshController: PullToRefreshController? = null, doneInitialLoad: Boolean = true, + loadingIndicator: @Composable () -> Unit = FlexibleImageGridDefaults::WideLoadingIndicator, onEndReached: (suspend () -> Unit)? = null ) { val scope = ImageGridHeaderScopeImpl() @@ -183,30 +221,32 @@ fun FlexibleImageGrid( when (gridState) { is LazyStaggeredGridState -> { - ImageGridPullToRefreshContainer( - modifier = modifier, - pullToRefreshController = pullToRefreshController - ) { - StaggeredImageGrid( - modifier = if (pullToRefreshController == null) modifier else Modifier, - gridState = gridState, + Box { + ImageGridPullToRefreshContainer( + modifier = modifier, + pullToRefreshController = pullToRefreshController + ) { + StaggeredImageGrid( + modifier = if (pullToRefreshController == null) modifier else Modifier, + userScrollEnabled = userScrollEnabled, + gridState = gridState, + contentPadding = contentPadding, + headerItems = scope.items, + images = images, + noImagesContent = noImagesContent, + onImageClick = onImageClick, + onEndReached = onEndReached + ) + } + + LoadingIndicatorContainer( + modifier = modifier, + doneInitialLoad = doneInitialLoad, contentPadding = contentPadding, - headerItems = scope.items, - images = images, - noImagesContent = noImagesContent, - onImageClick = onImageClick, - onEndReached = onEndReached + onInitialLoadCompleted = { gridState.requestScrollToItem(0) }, + content = loadingIndicator ) } - - InitialLoadingIndicator( - modifier = modifier, - doneInitialLoad = doneInitialLoad, - contentPadding = contentPadding, - onInitialLoadCompleted = { - gridState.requestScrollToItem(0) - } - ) } is LazyGridState -> { @@ -214,26 +254,28 @@ fun FlexibleImageGrid( modifier = modifier, pullToRefreshController = pullToRefreshController ) { - UniformImageGrid( - modifier = if (pullToRefreshController == null) modifier else Modifier, - gridState = gridState, - contentPadding = contentPadding, - headerItems = scope.items, - images = images, - noImagesContent = noImagesContent, - onImageClick = onImageClick, - onEndReached = onEndReached - ) - } - - InitialLoadingIndicator( - modifier = modifier, - doneInitialLoad = doneInitialLoad, - contentPadding = contentPadding, - onInitialLoadCompleted = { - gridState.scrollToItem(0) + Box { + UniformImageGrid( + modifier = if (pullToRefreshController == null) modifier else Modifier, + userScrollEnabled = userScrollEnabled, + gridState = gridState, + contentPadding = contentPadding, + headerItems = scope.items, + images = images, + noImagesContent = noImagesContent, + onImageClick = onImageClick, + onEndReached = onEndReached + ) + + LoadingIndicatorContainer( + modifier = modifier, + doneInitialLoad = doneInitialLoad, + contentPadding = contentPadding, + onInitialLoadCompleted = { gridState.scrollToItem(0) }, + content = loadingIndicator + ) } - ) + } } else -> { @@ -244,7 +286,8 @@ fun FlexibleImageGrid( @Deprecated( - message = "Use FlexibleImageGrid, which allows for passing in multiple header items, instead.", + message = "Use FlexibleImageGrid, which allows for passing in multiple header items, " + + "custom loading indicators, and controlling whether user scroll is allowed, instead.", replaceWith = ReplaceWith("FlexibleImageGrid") ) @Composable @@ -254,7 +297,7 @@ fun ImageGrid( uniformGridState: LazyGridState = rememberLazyGridState(), images: List, onImageClick: (Int, Image) -> Unit, - noImagesContent: @Composable () -> Unit = { NoImages() }, + noImagesContent: @Composable () -> Unit = FlexibleImageGridDefaults::NoImages, contentPadding: PaddingValues = PaddingValues(0.dp), filterComposable: (@Composable () -> Unit)? = null, pullToRefreshController: PullToRefreshController? = null, @@ -281,6 +324,7 @@ fun ImageGrid( @Composable private fun StaggeredImageGrid( modifier: Modifier = Modifier, + userScrollEnabled: Boolean = true, gridState: LazyStaggeredGridState = rememberLazyStaggeredGridState(), contentPadding: PaddingValues, headerItems: (List<@Composable () -> Unit>)? = null, @@ -297,7 +341,8 @@ private fun StaggeredImageGrid( modifier = modifier, contentPadding = contentPadding, horizontalArrangement = Arrangement.spacedBy(SMALL_SPACER.dp), - verticalItemSpacing = SMALL_SPACER.dp + verticalItemSpacing = SMALL_SPACER.dp, + userScrollEnabled = userScrollEnabled ) { headerItems?.forEachIndexed { index, element -> item(key = "header-$index", span = StaggeredGridItemSpan.FullLine) { @@ -335,6 +380,7 @@ private fun StaggeredImageGrid( @Composable private fun UniformImageGrid( modifier: Modifier = Modifier, + userScrollEnabled: Boolean = true, gridState: LazyGridState = rememberLazyGridState(), contentPadding: PaddingValues, headerItems: List<@Composable () -> Unit>? = null, @@ -351,7 +397,8 @@ private fun UniformImageGrid( modifier = modifier, contentPadding = contentPadding, horizontalArrangement = Arrangement.spacedBy(SMALL_SPACER.dp), - verticalArrangement = Arrangement.spacedBy(SMALL_SPACER.dp) + verticalArrangement = Arrangement.spacedBy(SMALL_SPACER.dp), + userScrollEnabled = userScrollEnabled ) { headerItems?.forEachIndexed { index, element -> item(key = "header-$index", span = { GridItemSpan(maxLineSpan) }) { @@ -386,16 +433,6 @@ private fun UniformImageGrid( } -@Composable -fun NoImages() { - Text( - text = "No images :(", - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth() - ) -} - - @Composable private fun LazyGridItemScope.ImagePreviewContainer( image: Image, 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 69a03950..d2a6d332 100644 --- a/app/src/main/java/moe/apex/breadboard/detailview/SearchResults.kt +++ b/app/src/main/java/moe/apex/breadboard/detailview/SearchResults.kt @@ -230,8 +230,7 @@ fun SearchResults(navController: NavController, source: ImageSource, tagList: Li } } else null, pullToRefreshController = pullToRefreshController, doneInitialLoad = doneInitialLoad, - onEndReached = viewModel::loadMore, - noImagesContent = { if (doneInitialLoad) { NoImages() } } + onEndReached = viewModel::loadMore ) } 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 351f892f..3e57481d 100644 --- a/app/src/main/java/moe/apex/breadboard/home/HomeScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/home/HomeScreen.kt @@ -1,17 +1,26 @@ package moe.apex.breadboard.home +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState 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.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.ToggleButton +import androidx.compose.material3.ToggleButtonDefaults import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable @@ -33,17 +42,23 @@ import androidx.compose.ui.unit.dp import androidx.navigation.NavController import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import moe.apex.breadboard.detailview.ImageGrid +import moe.apex.breadboard.detailview.FlexibleImageGrid +import moe.apex.breadboard.detailview.FlexibleImageGridDefaults import moe.apex.breadboard.preferences.Experiment +import moe.apex.breadboard.preferences.ImageSource import moe.apex.breadboard.preferences.LocalPreferences import moe.apex.breadboard.largeimageview.OffsetBasedLargeImageView import moe.apex.breadboard.tag.IgnoredTagsHelper +import moe.apex.breadboard.ui.theme.BreadboardTheme +import moe.apex.breadboard.util.FollowingProvider +import moe.apex.breadboard.util.FullscreenLoadingSpinner import moe.apex.breadboard.util.MainScreenScaffold 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.SmallTitleBar +import moe.apex.breadboard.util.TINY_SPACER import moe.apex.breadboard.util.bottomAppBarAndNavBarHeight import moe.apex.breadboard.util.differenceOlderThan import moe.apex.breadboard.util.onScroll @@ -64,6 +79,7 @@ fun HomeScreen( val prefs = LocalPreferences.current val viewModel = getGlobalViewModel() val recommendationsProvider by viewModel.recommendationsProvider.collectAsState() + val followingProvider by viewModel.followingProvider.collectAsState() val blockedTags by rememberUpdatedState(prefs.blockedTags) val unfollowedTags by rememberUpdatedState(prefs.unfollowedTags) val builtInIgnoredTags by rememberUpdatedState(prefs.internalIgnoreList) @@ -72,37 +88,54 @@ fun HomeScreen( var shouldShowLargeImage by remember { mutableStateOf(false) } var selectedImageIndex by remember { mutableIntStateOf(0) } - val blur = prefs.isExperimentEnabled(Experiment.IMMERSIVE_UI_EFFECTS) - - val _onRefresh: suspend () -> Unit = { - recommendationsProvider?.let { - it.replaceBlockedTags(blockedTags) - it.replaceUnfollowedTags(unfollowedTags + builtInIgnoredTags) - it.prepareRecommendedTags() - it.recommendImages() - it.resetGridStates() - } - } - val onRefresh by rememberUpdatedState(_onRefresh) + val pagerState = rememberPagerState { 2 } - val pullToRefreshController = rememberPullToRefreshController(onRefresh = onRefresh) + val blur = prefs.isExperimentEnabled(Experiment.IMMERSIVE_UI_EFFECTS) MainScreenScaffold( - title = "Breadboard", - largeTopBar = false, - scrollBehavior = scrollBehavior, - addBottomPadding = false, - blur = shouldShowLargeImage && blur, - additionalActions = { - recommendationsProvider?.let { - ScrollToTopArrow( - staggeredGridState = it.staggeredGridState, - uniformGridState = it.uniformGridState + topAppBar = { + Column { + SmallTitleBar( + title = "Breadboard", + scrollBehavior = scrollBehavior, + additionalActions = { + val currentProvider = + if (pagerState.currentPage == 0) recommendationsProvider else followingProvider + currentProvider?.let { + ScrollToTopArrow( + staggeredGridState = it.staggeredGridState, + uniformGridState = it.uniformGridState + ) { + scrollBehavior.state.contentOffset = 0f + } + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = BreadboardTheme.colors.titleBar, + scrolledContainerColor = BreadboardTheme.colors.titleBar + ) + ) + Row( + modifier = Modifier + .background(color = BreadboardTheme.colors.titleBar) + .padding(horizontal = SMALL_LARGE_SPACER.dp, vertical = TINY_SPACER.dp), + horizontalArrangement = Arrangement.spacedBy(SMALL_SPACER.dp) ) { - scrollBehavior.state.contentOffset = 0f + TagPageIndicator( + label = "For You", + selected = pagerState.currentPage == 0, + onClick = { scope.launch { pagerState.animateScrollToPage(0) } } + ) + TagPageIndicator( + label = "Following", + selected = pagerState.currentPage == 1, + onClick = { scope.launch { pagerState.animateScrollToPage(1) } } + ) } } - } + }, + addBottomPadding = false, + blur = shouldShowLargeImage && blur, ) { padding -> LaunchedEffect(Unit) { if (differenceOlderThan(7.days, prefs.internalIgnoreListTimestamp)) { @@ -121,12 +154,7 @@ fun HomeScreen( } if (builtInIgnoredTags.isEmpty()) { - WideLinearWavyProgressIndicator( - modifier = Modifier - .fillMaxWidth() - .padding(padding) - .padding(SMALL_LARGE_SPACER.dp) - ) + FullscreenLoadingSpinner() } else { if (recommendationsProvider == null) { LaunchedEffect(Unit) { @@ -145,62 +173,174 @@ fun HomeScreen( viewModel.setRecommendationsProvider(newProvider) } } + if (followingProvider == null) { + LaunchedEffect(Unit) { + val newProvider = FollowingProvider( + followedArtists = prefs.followedTags, + auth = prefs.authFor(ImageSource.DANBOORU, context), + showAllRatings = prefs.recommendAllRatings + ) + viewModel.setFollowingProvider(newProvider) + } + } } - recommendationsProvider?.let { provider -> - ImageGrid( - modifier = Modifier - .padding(padding) - .nestedScroll(scrollBehavior.nestedScrollConnection) - .onScroll(provider.staggeredGridState) { - navBarVisibilityCallback(!it.lastScrolledForward) + HorizontalPager( + state = pagerState, + modifier = Modifier + .fillMaxSize() + .padding(padding), + beyondViewportPageCount = 1, + verticalAlignment = Alignment.Top, + userScrollEnabled = !shouldShowLargeImage + ) { page -> + if (page == 0) { + recommendationsProvider?.let { provider -> + val state = if (prefs.useStaggeredGrid) { + provider.staggeredGridState + } else { + provider.uniformGridState } - .onScroll(provider.uniformGridState) { - navBarVisibilityCallback(!it.lastScrolledForward) - }, - staggeredGridState = provider.staggeredGridState, - uniformGridState = provider.uniformGridState, - images = provider.recommendedImages, - noImagesContent = { - if (!provider.doneInitialLoad) { - return@ImageGrid + + val onRefresh: suspend () -> Unit by rememberUpdatedState { + provider.let { + it.replaceBlockedTags(blockedTags) + it.replaceUnfollowedTags(unfollowedTags + builtInIgnoredTags) + it.prepareRecommendedTags() + it.recommendImages() + it.resetGridStates() + } } - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text("No recommendations right now.") - TextButton( - onClick = { - pullToRefreshController.refresh(animate = true) + + val ptrController = rememberPullToRefreshController( + enabled = provider.doneInitialLoad, + onRefresh = onRefresh + ) + + FlexibleImageGrid( + gridState = state, + modifier = Modifier + .nestedScroll(scrollBehavior.nestedScrollConnection) + .onScroll(state) { + navBarVisibilityCallback(!it.lastScrolledForward) + }, + userScrollEnabled = !shouldShowLargeImage, + images = provider.recommendedImages, + onImageClick = { index, _ -> + Snapshot.withMutableSnapshot { + selectedImageIndex = index + shouldShowLargeImage = true } - ) { - Icon(Icons.Rounded.Refresh, contentDescription = null) - Spacer(Modifier.width(SMALL_SPACER.dp)) - Text("Refresh") - } + }, + noImagesContent = { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text("No recommendations right now.") + TextButton( + onClick = { + ptrController.refresh(animate = true) + } + ) { + Icon(Icons.Rounded.Refresh, contentDescription = null) + Spacer(Modifier.width(SMALL_SPACER.dp)) + Text("Refresh") + } + } + }, + contentPadding = PaddingValues( + start = SMALL_LARGE_SPACER.dp, + end = SMALL_LARGE_SPACER.dp, + top = SMALL_LARGE_SPACER.dp, + bottom = bottomAppBarAndNavBarHeight + ), + pullToRefreshController = ptrController, + doneInitialLoad = provider.doneInitialLoad, + loadingIndicator = FlexibleImageGridDefaults::RoundLoadingIndicator, + onEndReached = { provider.recommendImages() }, + ) + } + } else { + followingProvider?.let { provider -> + val state = if (prefs.useStaggeredGrid) { + provider.staggeredGridState + } else { + provider.uniformGridState } - }, - onImageClick = { index, _ -> - Snapshot.withMutableSnapshot { - selectedImageIndex = index - shouldShowLargeImage = true + + val onRefresh: suspend () -> Unit by rememberUpdatedState { + provider.let { + it.replaceFollowedArtists(prefs.followedTags) + it.reset() + it.loadMore() + it.resetGridStates() + } } - }, - contentPadding = PaddingValues( - start = SMALL_LARGE_SPACER.dp, - end = SMALL_LARGE_SPACER.dp, - top = SMALL_LARGE_SPACER.dp, - bottom = bottomAppBarAndNavBarHeight - ), - pullToRefreshController = pullToRefreshController, - doneInitialLoad = provider.doneInitialLoad, - onEndReached = { provider.recommendImages() }, - ) + + val ptrController = rememberPullToRefreshController( + enabled = provider.doneInitialLoad, + onRefresh = onRefresh + ) + + FlexibleImageGrid( + gridState = state, + modifier = Modifier + .nestedScroll(scrollBehavior.nestedScrollConnection) + .onScroll(state) { + navBarVisibilityCallback(!it.lastScrolledForward) + }, + userScrollEnabled = !shouldShowLargeImage, + images = provider.images, + onImageClick = { index, _ -> + Snapshot.withMutableSnapshot { + selectedImageIndex = index + shouldShowLargeImage = true + } + }, + noImagesContent = { + if (!provider.doneInitialLoad) { + return@FlexibleImageGrid + } + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + if (prefs.followedTags.isEmpty()){ + "You aren't following anyone yet." + } else { + "No new posts from artists you follow." + } + ) + TextButton( + onClick = { + ptrController.refresh(animate = true) + } + ) { + Icon(Icons.Rounded.Refresh, contentDescription = null) + Spacer(Modifier.width(SMALL_SPACER.dp)) + Text("Refresh") + } + } + }, + contentPadding = PaddingValues( + start = SMALL_LARGE_SPACER.dp, + end = SMALL_LARGE_SPACER.dp, + top = SMALL_LARGE_SPACER.dp, + bottom = bottomAppBarAndNavBarHeight + ), + pullToRefreshController = ptrController, + doneInitialLoad = provider.doneInitialLoad, + loadingIndicator = FlexibleImageGridDefaults::RoundLoadingIndicator, + onEndReached = { provider.loadMore() }, + ) + } + } } } - val recommendedImages = recommendationsProvider?.recommendedImages + val recommendedImages = if (pagerState.currentPage == 0) recommendationsProvider?.recommendedImages else followingProvider?.images OffsetBasedLargeImageView( navController = navController, @@ -218,3 +358,22 @@ fun HomeScreen( } } } + + +@Composable +private fun RowScope.TagPageIndicator( + label: String, + selected: Boolean, + onClick: () -> Unit +) { + ToggleButton( + checked = selected, + modifier = Modifier.weight(1f), + onCheckedChange = { onClick() }, + colors = ToggleButtonDefaults.toggleButtonColors( + containerColor = ToggleButtonDefaults.toggleButtonColors().disabledContainerColor + ) + ) { + Text(label) + } +} 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 fbd0a74d..630c4b68 100644 --- a/app/src/main/java/moe/apex/breadboard/image/ImageBoard.kt +++ b/app/src/main/java/moe/apex/breadboard/image/ImageBoard.kt @@ -423,7 +423,6 @@ object Danbooru : ImageBoard { override val localFilterType = ImageBoardRequirement.RECOMMENDED private val artistSearchUrl = "${baseUrl}artists.json?only=name,urls,other_names,tag&search[any_name_matches]=%s" - private val sfwUrl = "https://safebooru.donmai.us/posts.json?tags=%s&limit=20" override fun parseImage(e: JSONObject): Image? { if (e.isNull("md5")) return null @@ -537,8 +536,41 @@ object Danbooru : ImageBoard { ) } - suspend fun getMostPopularSfwPosts(artistTag: String): List { - val url = sfwUrl.format("$artistTag order:favcount") + + override fun getRatingFromString(rating: String): ImageRating { + return when (rating) { + "g" -> ImageRating.SAFE + "s" -> ImageRating.SENSITIVE + "q" -> ImageRating.QUESTIONABLE + "e" -> ImageRating.EXPLICIT + else -> ImageRating.UNKNOWN + } + } +} + + +object DanbooruSafe : ImageBoard by Danbooru { + override val baseUrl = "https://safebooru.donmai.us/" + override val autoCompleteSearchUrl = "${baseUrl}autocomplete.json?search[query]=%s&search[type]=tag_query&limit=10" + override val imageSearchUrl = "${baseUrl}posts.json?tags=%s&page=%d&limit=100" + override val authenticatedImageSearchUrl = "$imageSearchUrl&api_key=%s&login=%s" + + private val popularUrl = "${baseUrl}posts.json?tags=%s order:favcount&limit=20" + + override fun buildImageSearchUrl(tags: String, page: Int, auth: ImageBoardAuth?): String { + val encodedTags = URLEncoder.encode(tags, "utf-8") + + val url = if (auth != null) { + authenticatedImageSearchUrl.format(encodedTags, page, auth.apiKey, auth.user) + } else { + imageSearchUrl.format(encodedTags, page) + } + return url + } + + + override suspend fun loadPage(tags: String, page: Int, auth: ImageBoardAuth?): List { + val url = buildImageSearchUrl(tags, page, auth) val body = RequestUtil.get(url) if (body.isEmpty()) return emptyList() @@ -547,21 +579,27 @@ object Danbooru : ImageBoard { for (i in 0 until json.length()) { val e = json.getJSONObject(i) - parseImage(e)?.let { subjects.add(it) } + Danbooru.parseImage(e)?.let { subjects.add(it) } } return subjects.toList() } - override fun getRatingFromString(rating: String): ImageRating { - return when (rating) { - "g" -> ImageRating.SAFE - "s" -> ImageRating.SENSITIVE - "q" -> ImageRating.QUESTIONABLE - "e" -> ImageRating.EXPLICIT - else -> ImageRating.UNKNOWN + suspend fun getMostPopularPosts(artistTag: String): List { + val url = popularUrl.format(artistTag) + val body = RequestUtil.get(url) + if (body.isEmpty()) return emptyList() + + val json = JSONArray(body) + val subjects = mutableListOf() + + for (i in 0 until json.length()) { + val e = json.getJSONObject(i) + Danbooru.parseImage(e)?.let { subjects.add(it) } } + + return subjects.toList() } } 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 4df32be0..11968aa1 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/BlockedTagsScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/BlockedTagsScreen.kt @@ -112,7 +112,7 @@ fun BlockedTagsScreen(navController: NavHostController) { } } showAddDialog = false - viewModel.setRecommendationsProvider(null) + viewModel.resetProviders() } ) { Text("Add") @@ -222,7 +222,7 @@ fun BlockedTagsScreen(navController: NavHostController) { tag ) } - viewModel.setRecommendationsProvider(null) + viewModel.resetProviders() } ) { Icon( 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 ef0db442..a5ec572b 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/Pref.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/Pref.kt @@ -97,6 +97,7 @@ data object PrefNames { const val INTERNAL_IGNORE_LIST = "internal_ignore_list" const val AUTOPLAY_VIDEOS = "autoplay_videos" const val UNIFIED_INFO_SHEET = "unified_info_sheet" + const val FOLLOWED_TAGS = "followed_tags" } @@ -130,6 +131,7 @@ object PreferenceKeys { val INTERNAL_IGNORE_LIST = stringSetPreferencesKey(PrefNames.INTERNAL_IGNORE_LIST) val AUTOPLAY_VIDEOS = stringPreferencesKey(PrefNames.AUTOPLAY_VIDEOS) val UNIFIED_INFO_SHEET = booleanPreferencesKey(PrefNames.UNIFIED_INFO_SHEET) + val FOLLOWED_TAGS = stringSetPreferencesKey(PrefNames.FOLLOWED_TAGS) } @@ -226,7 +228,8 @@ data class Prefs( val internalIgnoreListTimestamp: Long, val internalIgnoreList: Set, val autoplayVideos: AutoplayVideosMode, - val unifiedInfoSheet: Boolean + val unifiedInfoSheet: Boolean, + val followedTags: Set ) { companion object { val DEFAULT = Prefs( @@ -259,6 +262,7 @@ data class Prefs( internalIgnoreList = emptySet(), autoplayVideos = AutoplayVideosMode.OFF, unifiedInfoSheet = false, // Unified is called 'Classic' in the UI + followedTags = emptySet() ) } @@ -321,7 +325,8 @@ class UserPreferencesRepository(private val dataStore: DataStore) { PreferenceKeys.RECOMMENDATIONS_POOL_SIZE 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) + PreferenceKeys.UNIFIED_INFO_SHEET to PrefMeta(PrefCategory.SETTING), + PreferenceKeys.FOLLOWED_TAGS to PrefMeta(PrefCategory.SETTING, mergeable = true) ) } @@ -819,6 +824,7 @@ class UserPreferencesRepository(private val dataStore: DataStore) { val internalIgnoreList = preferences[PreferenceKeys.INTERNAL_IGNORE_LIST] ?: Prefs.DEFAULT.internalIgnoreList val autoplayVideos = preferences[PreferenceKeys.AUTOPLAY_VIDEOS]?.let { AutoplayVideosMode.valueOf(it) } ?: Prefs.DEFAULT.autoplayVideos val unifiedInfoSheet = preferences[PreferenceKeys.UNIFIED_INFO_SHEET] ?: Prefs.DEFAULT.unifiedInfoSheet + val followedTags = preferences[PreferenceKeys.FOLLOWED_TAGS] ?: Prefs.DEFAULT.followedTags return Prefs( dataSaver, @@ -849,7 +855,8 @@ class UserPreferencesRepository(private val dataStore: DataStore) { internalIgnoreListTimestamp, internalIgnoreList, autoplayVideos, - unifiedInfoSheet + unifiedInfoSheet, + followedTags, ) } } 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..aeecc896 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/Preferences.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/Preferences.kt @@ -121,7 +121,7 @@ fun PreferencesScreen(navController: NavHostController) { ) } showAuthDialog = false - viewModel.setRecommendationsProvider(null) + viewModel.resetProviders() } } @@ -401,7 +401,7 @@ fun PreferencesScreen(navController: NavHostController) { it ) } - viewModel.setRecommendationsProvider(null) + viewModel.resetProviders() } } item { 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 602b6169..fe356ec4 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/RecommendationsSettingsScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/RecommendationsSettingsScreen.kt @@ -144,10 +144,6 @@ fun RecommendationsSettingsScreen(navController: NavHostController) { val pagerState = rememberPagerState(ImageSource.entries.indexOf(prefs.imageSource)) { topTags.size } - fun resetRecommendations() { - viewModel.setRecommendationsProvider(null) - } - fun showUnfollowedToast(tagName: String) { showToast(context, "Ignored $tagName") } @@ -172,7 +168,7 @@ fun RecommendationsSettingsScreen(navController: NavHostController) { showUnfollowedToast(tagName) } }.invokeOnCompletion { - resetRecommendations() + viewModel.resetProviders() } } @@ -387,7 +383,7 @@ fun RecommendationsSettingsScreen(navController: NavHostController) { it ) } - resetRecommendations() + viewModel.resetProviders() } } item { @@ -404,7 +400,7 @@ fun RecommendationsSettingsScreen(navController: NavHostController) { it.roundToInt() ) } - resetRecommendations() + viewModel.resetProviders() } } item { @@ -421,7 +417,7 @@ fun RecommendationsSettingsScreen(navController: NavHostController) { it.roundToInt() ) } - resetRecommendations() + viewModel.resetProviders() } AnimatedVisibility( visible = prefs.recommendationsTagCount > prefs.recommendationsPoolSize, diff --git a/app/src/main/java/moe/apex/breadboard/util/FollowingProvider.kt b/app/src/main/java/moe/apex/breadboard/util/FollowingProvider.kt new file mode 100644 index 00000000..63273237 --- /dev/null +++ b/app/src/main/java/moe/apex/breadboard/util/FollowingProvider.kt @@ -0,0 +1,97 @@ +package moe.apex.breadboard.util + +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.mutableStateSetOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.Snapshot +import moe.apex.breadboard.image.Danbooru +import moe.apex.breadboard.image.DanbooruSafe +import moe.apex.breadboard.image.Image +import moe.apex.breadboard.image.ImageBoardAuth +import moe.apex.breadboard.viewmodel.GridStateHolder +import moe.apex.breadboard.viewmodel.GridStateHolderDelegate + +class FollowingProvider( + followedArtists: Set, + private val auth: ImageBoardAuth?, + private val showAllRatings: Boolean, +) : GridStateHolder by GridStateHolderDelegate() { + private val source = if (showAllRatings) Danbooru else DanbooruSafe + private val mutableFollowedArtists = mutableStateSetOf().apply { addAll(followedArtists) } + val followedArtists: Set + get() = mutableFollowedArtists.toSet() + + fun replaceFollowedArtists(artists: Set) { + Snapshot.withMutableSnapshot { + mutableFollowedArtists.clear() + mutableFollowedArtists.addAll(artists) + } + } + + var images = mutableStateListOf() + var doneInitialLoad by mutableStateOf(false) + private var pageNumber by mutableIntStateOf(Danbooru.firstPageIndex) + + private var isLoading by mutableStateOf(false) + private var shouldKeepSearching by mutableStateOf(true) + + suspend fun loadMore() { + if (isLoading || !shouldKeepSearching || followedArtists.isEmpty()) { + if (followedArtists.isEmpty()) doneInitialLoad = true + return + } + + try { + isLoading = true + Log.d("FollowingProvider", "Using source: ${source.baseUrl} (showAllRatings: $showAllRatings)") + val tagLimit = if (auth == null) 2 else 12 + + val batches = followedArtists.toList().chunked(tagLimit) + val allResults = mutableListOf() + + for (batch in batches) { + val searchQuery = batch.joinToString(" ") { "~$it" } + val results = source.loadPage( + tags = searchQuery, + page = pageNumber, + auth = auth + ) + allResults.addAll(results) + } + + // Deduplicate and sort by ID descending + val sortedResults = allResults.distinctBy { it.id } + .sortedByDescending { it.id?.toLongOrNull() ?: 0L } + + if (sortedResults.isEmpty()) { + shouldKeepSearching = false + } else { + if (pageNumber == Danbooru.firstPageIndex) { + Snapshot.withMutableSnapshot { + images.clear() + images.addAll(sortedResults) + } + } else { + val newImages = sortedResults.filter { newImg -> images.none { it.id == newImg.id } } + images += newImages + } + pageNumber++ + } + } catch (e: Exception) { + Log.e("FollowingProvider", "Error fetching following feed", e) + shouldKeepSearching = false + } finally { + isLoading = false + doneInitialLoad = true + } + } + + fun reset() { + pageNumber = Danbooru.firstPageIndex + shouldKeepSearching = true + } +} diff --git a/app/src/main/java/moe/apex/breadboard/util/Modifiers.kt b/app/src/main/java/moe/apex/breadboard/util/Modifiers.kt index b8cde3ec..ff8be3ab 100644 --- a/app/src/main/java/moe/apex/breadboard/util/Modifiers.kt +++ b/app/src/main/java/moe/apex/breadboard/util/Modifiers.kt @@ -1,28 +1,16 @@ package moe.apex.breadboard.util -import androidx.compose.foundation.lazy.grid.LazyGridState -import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState +import androidx.compose.foundation.gestures.ScrollableState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier @Composable -fun Modifier.onScroll(gridState: LazyStaggeredGridState, callback: (LazyStaggeredGridState) -> Unit): Modifier { - LaunchedEffect(gridState.isScrollInProgress, gridState.lastScrolledForward) { - if (gridState.isScrollInProgress) { - callback(gridState) - } - } - return this -} - - -@Composable -fun Modifier.onScroll(gridState: LazyGridState, callback: (LazyGridState) -> Unit): Modifier { - LaunchedEffect(gridState.isScrollInProgress, gridState.lastScrolledForward) { - if (gridState.isScrollInProgress) { - callback(gridState) +fun Modifier.onScroll(scrollableState: ScrollableState, callback: (ScrollableState) -> Unit): Modifier { + LaunchedEffect(scrollableState.isScrollInProgress, scrollableState.lastScrolledForward) { + if (scrollableState.isScrollInProgress) { + callback(scrollableState) } } return this 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 ebbb5900..2138cb0f 100644 --- a/app/src/main/java/moe/apex/breadboard/util/Ui.kt +++ b/app/src/main/java/moe/apex/breadboard/util/Ui.kt @@ -89,6 +89,7 @@ import androidx.compose.material3.SheetValue import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarColors import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.material3.VerticalDivider @@ -268,15 +269,16 @@ fun SmallTitleBar( title: String, scrollBehavior: TopAppBarScrollBehavior? = null, navController: NavController? = null, + colors: TopAppBarColors = TopAppBarDefaults.topAppBarColors( + scrolledContainerColor = BreadboardTheme.colors.titleBar + ), additionalActions: @Composable RowScope.() -> Unit = { } ) { TopAppBar( title = { Text(title) }, scrollBehavior = scrollBehavior, actions = additionalActions, - colors = TopAppBarDefaults.topAppBarColors( - scrolledContainerColor = BreadboardTheme.colors.titleBar - ), + colors = colors, navigationIcon = { NavigationIcon(navController) }, windowInsets = TopAppBarDefaults.windowInsets.only(WindowInsetsSides.Vertical) ) @@ -1212,11 +1214,16 @@ fun CombinedClickableAction( } -data class PullToRefreshController( +class PullToRefreshController( + enabled: Boolean, val state: PullToRefreshState, - val indicator: @Composable BoxScope.(PullToRefreshController) -> Unit, - private val refreshCallback: suspend () -> Unit + indicator: @Composable BoxScope.(PullToRefreshController) -> Unit, + refreshCallback: suspend () -> Unit ) { + var enabled by mutableStateOf(enabled) + var indicator by mutableStateOf(indicator) + internal var refreshCallback by mutableStateOf(refreshCallback) + /* We are using the main thread because this is the thread that animations must run on. In this case it's for showing/hiding the indicator. We'll explicitly use the IO thread when doing the callback. */ @@ -1260,15 +1267,23 @@ fun rememberPullToRefreshController( controller = it ) }, + enabled: Boolean = true, onRefresh: suspend () -> Unit = { } ): PullToRefreshController { - return remember { + val controller = remember(state) { PullToRefreshController( + enabled = enabled, state = state, indicator = indicator, refreshCallback = onRefresh ) } + + controller.enabled = enabled + controller.refreshCallback = onRefresh + controller.indicator = indicator + + return controller } diff --git a/app/src/main/java/moe/apex/breadboard/viewmodel/ArtistProfileViewModel.kt b/app/src/main/java/moe/apex/breadboard/viewmodel/ArtistProfileViewModel.kt index e7a2cc5f..fdc53e89 100644 --- a/app/src/main/java/moe/apex/breadboard/viewmodel/ArtistProfileViewModel.kt +++ b/app/src/main/java/moe/apex/breadboard/viewmodel/ArtistProfileViewModel.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import moe.apex.breadboard.artist.Artist import moe.apex.breadboard.image.Danbooru +import moe.apex.breadboard.image.DanbooruSafe import moe.apex.breadboard.image.Image @@ -27,7 +28,7 @@ class ArtistProfileViewModel(private val artistTag: String): ViewModel() { Log.i("ArtistProfileViewModel", "Loading artist profile for $artistTag") _artistProfile.value = Danbooru.getArtist(artistTag) - _images.value = Danbooru.getMostPopularSfwPosts(artistTag) + _images.value = DanbooruSafe.getMostPopularPosts(artistTag) _isInitialised.value = true } diff --git a/app/src/main/java/moe/apex/breadboard/viewmodel/ViewModel.kt b/app/src/main/java/moe/apex/breadboard/viewmodel/ViewModel.kt index 18fcb39c..b2dbcbf4 100644 --- a/app/src/main/java/moe/apex/breadboard/viewmodel/ViewModel.kt +++ b/app/src/main/java/moe/apex/breadboard/viewmodel/ViewModel.kt @@ -13,6 +13,7 @@ import moe.apex.breadboard.image.Image import moe.apex.breadboard.preferences.DataSaver import moe.apex.breadboard.tag.TagSuggestion import moe.apex.breadboard.util.RecommendationsProvider +import moe.apex.breadboard.util.FollowingProvider import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.lifecycle.viewmodel.compose.viewModel @@ -30,6 +31,9 @@ class BreadboardViewModel : ViewModel() { private val _recommendationsProvider = MutableStateFlow(null) val recommendationsProvider: StateFlow = _recommendationsProvider.asStateFlow() + private val _followingProvider = MutableStateFlow(null) + val followingProvider: StateFlow = _followingProvider.asStateFlow() + private val _downloadingImages = MutableStateFlow>(emptySet()) val downloadingImages: StateFlow> = _downloadingImages.asStateFlow() @@ -46,6 +50,15 @@ class BreadboardViewModel : ViewModel() { _recommendationsProvider.value = provider } + fun setFollowingProvider(provider: FollowingProvider?) { + _followingProvider.value = provider + } + + fun resetProviders() { + _recommendationsProvider.value = null + _followingProvider.value = null + } + fun setIncognito(value: Boolean) { _incognito.value = value } From 3bf8ab0f758b29891409a7dca7dcf7560c4a0f0f Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Thu, 2 Jul 2026 03:55:21 +0100 Subject: [PATCH 04/59] Refactor scroll to top button --- .../breadboard/detailview/SearchResults.kt | 3 +- .../apex/breadboard/favourites/Favourites.kt | 3 +- .../moe/apex/breadboard/home/HomeScreen.kt | 7 +- .../main/java/moe/apex/breadboard/util/Ui.kt | 104 ++++++++++++++---- 4 files changed, 85 insertions(+), 32 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 d2a6d332..54b5876d 100644 --- a/app/src/main/java/moe/apex/breadboard/detailview/SearchResults.kt +++ b/app/src/main/java/moe/apex/breadboard/detailview/SearchResults.kt @@ -175,8 +175,7 @@ fun SearchResults(navController: NavController, source: ImageSource, tagList: Li additionalActions = { if (doneInitialLoad) { ScrollToTopArrow( - staggeredGridState = viewModel.staggeredGridState, - uniformGridState = viewModel.uniformGridState, + scrollableState = if (prefs.useStaggeredGrid) viewModel.staggeredGridState else viewModel.uniformGridState, animate = !filterLocally || Experiment.ALWAYS_ANIMATE_SCROLL.isEnabled(), ) } 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 18e3f2b5..47208807 100644 --- a/app/src/main/java/moe/apex/breadboard/favourites/Favourites.kt +++ b/app/src/main/java/moe/apex/breadboard/favourites/Favourites.kt @@ -124,8 +124,7 @@ fun FavouritesPage( blur = isImageCarouselVisible && blur, additionalActions = { ScrollToTopArrow( - staggeredGridState = viewModel.staggeredGridState, - uniformGridState = viewModel.uniformGridState, + scrollableState = if (prefs.useStaggeredGrid) viewModel.staggeredGridState else viewModel.uniformGridState, animate = Experiment.ALWAYS_ANIMATE_SCROLL.isEnabled() ) { navBarVisibilityCallback(true) 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 3e57481d..87f64339 100644 --- a/app/src/main/java/moe/apex/breadboard/home/HomeScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/home/HomeScreen.kt @@ -97,16 +97,15 @@ fun HomeScreen( Column { SmallTitleBar( title = "Breadboard", - scrollBehavior = scrollBehavior, additionalActions = { val currentProvider = if (pagerState.currentPage == 0) recommendationsProvider else followingProvider currentProvider?.let { ScrollToTopArrow( - staggeredGridState = it.staggeredGridState, - uniformGridState = it.uniformGridState + scrollableState = if (prefs.useStaggeredGrid) it.staggeredGridState else it.uniformGridState, + animate = true ) { - scrollBehavior.state.contentOffset = 0f + navBarVisibilityCallback(true) } } }, 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 2138cb0f..6ea9bd98 100644 --- a/app/src/main/java/moe/apex/breadboard/util/Ui.kt +++ b/app/src/main/java/moe/apex/breadboard/util/Ui.kt @@ -1,6 +1,5 @@ package moe.apex.breadboard.util -import android.annotation.SuppressLint import android.app.Activity import android.content.ClipData import android.content.Context @@ -21,6 +20,7 @@ import androidx.compose.foundation.LocalIndication import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.ScrollableState import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement @@ -1305,44 +1305,100 @@ object PullToRefreshControllerDefaults { /** A button that scrolls image grids back to the top. - Set [animate] to false to scroll immediately without animation. This option is mostly intended - 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") +Set [animate] to false to scroll immediately without animation. This option is mostly intended +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). */ @Composable fun ScrollToTopArrow( - staggeredGridState: LazyStaggeredGridState, - uniformGridState: LazyGridState, + scrollableState: ScrollableState, animate: Boolean = true, alsoOnClick: (() -> Unit)? = null ) { - val scope = rememberCoroutineScope() - val isScrolledPastFirst by remember(staggeredGridState, uniformGridState) { - derivedStateOf { staggeredGridState.firstVisibleItemIndex != 0 || uniformGridState.firstVisibleItemIndex != 0 } + if (scrollableState is LazyStaggeredGridState) { + ScrollToTopArrow( + lazyStaggeredGridState = scrollableState, + animate = animate, + alsoOnClick = alsoOnClick + ) + } else if (scrollableState is LazyGridState) { + ScrollToTopArrow( + lazyGridState = scrollableState, + animate = animate, + alsoOnClick = alsoOnClick + ) + } else { + throw NotImplementedError("Not implemented for non-grid scroll states.") } +} + + +@Composable +private fun ScrollToTopArrow( + lazyGridState: LazyGridState, + animate: Boolean = true, + alsoOnClick: (() -> Unit)? = null +) { + val isScrolledPastFirst by remember(lazyGridState) { + derivedStateOf { lazyGridState.firstVisibleItemIndex != 0 } + } + + ScrollToTopArrow( + visible = isScrolledPastFirst, + onScrollToTop = { + if (animate) { + lazyGridState.animateScrollToItem(0) + } else { + lazyGridState.scrollToItem(0) + } + }, + onClick = alsoOnClick + ) +} + + +@Composable +private fun ScrollToTopArrow( + lazyStaggeredGridState: LazyStaggeredGridState, + animate: Boolean = true, + alsoOnClick: (() -> Unit)? = null +) { + val isScrolledPastFirst by remember(lazyStaggeredGridState) { + derivedStateOf { lazyStaggeredGridState.firstVisibleItemIndex != 0 } + } + + ScrollToTopArrow( + visible = isScrolledPastFirst, + onScrollToTop = { + if (animate) { + lazyStaggeredGridState.animateScrollToItem(0) + } else { + lazyStaggeredGridState.scrollToItem(0) + } + }, + onClick = alsoOnClick + ) +} + + +@Composable +private fun ScrollToTopArrow( + visible: Boolean, + onScrollToTop: suspend () -> Unit, + onClick: (() -> Unit)? = null +) { + val scope = rememberCoroutineScope() AnimatedVisibility( enter = fadeIn(), exit = fadeOut(), - visible = isScrolledPastFirst + visible = visible ) { IconButton( onClick = { scope.launch { - if (animate) { - staggeredGridState.animateScrollToItem(0) - } else { - staggeredGridState.scrollToItem(0) - } - } - scope.launch { - if (animate) { - uniformGridState.animateScrollToItem(0) - } else { - uniformGridState.scrollToItem(0) - } + onScrollToTop() } - alsoOnClick?.invoke() + onClick?.invoke() } ) { Icon(Icons.Rounded.KeyboardArrowUp, contentDescription = "Scroll to top") From 2c27eebc19d0d5fc437b1ff324deec7f3b16893f Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Thu, 2 Jul 2026 07:34:33 +0100 Subject: [PATCH 05/59] Add FRE and support blocked tags in Following --- .../apex/breadboard/detailview/ImageGrid.kt | 24 +-- .../moe/apex/breadboard/home/HomeScreen.kt | 154 ++++++++++++++++-- .../apex/breadboard/util/FollowingProvider.kt | 25 ++- .../breadboard/util/RecommendationsHelper.kt | 21 +++ .../util/RecommendationsHelperTest.kt | 34 ++++ 5 files changed, 229 insertions(+), 29 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 7b0a1f4d..ae6fb424 100644 --- a/app/src/main/java/moe/apex/breadboard/detailview/ImageGrid.kt +++ b/app/src/main/java/moe/apex/breadboard/detailview/ImageGrid.kt @@ -350,6 +350,12 @@ private fun StaggeredImageGrid( } } + if (images.isEmpty()) { + item(key = "no-images", span = StaggeredGridItemSpan.FullLine) { + noImagesContent() + } + } + itemsIndexed(images, key = { _, image -> image.previewUrl }) { index, image -> StaggeredImagePreviewContainer(image, index, onImageClick) } @@ -364,12 +370,6 @@ private fun StaggeredImageGrid( } } - if (images.isEmpty()) { - item(key = "no-images", span = StaggeredGridItemSpan.FullLine) { - noImagesContent() - } - } - item(key = "spacer", span = StaggeredGridItemSpan.FullLine) { NavBarHeightVerticalSpacer() } @@ -406,6 +406,12 @@ private fun UniformImageGrid( } } + if (images.isEmpty()) { + item(key = "no-images", span = { GridItemSpan(maxLineSpan) }) { + noImagesContent() + } + } + itemsIndexed(images, key = { _, image -> image.previewUrl }) { index, image -> ImagePreviewContainer(image, index, onImageClick) } @@ -420,12 +426,6 @@ private fun UniformImageGrid( } } - if (images.isEmpty()) { - item(key = "no-images", span = { GridItemSpan(maxLineSpan) }) { - noImagesContent() - } - } - item(key = "spacer", span = { GridItemSpan(maxLineSpan) }) { NavBarHeightVerticalSpacer() } 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 87f64339..1faffe45 100644 --- a/app/src/main/java/moe/apex/breadboard/home/HomeScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/home/HomeScreen.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -15,8 +16,12 @@ import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.ToggleButton @@ -28,6 +33,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -48,11 +54,21 @@ import moe.apex.breadboard.preferences.Experiment import moe.apex.breadboard.preferences.ImageSource import moe.apex.breadboard.preferences.LocalPreferences import moe.apex.breadboard.largeimageview.OffsetBasedLargeImageView +import moe.apex.breadboard.preferences.PreferenceKeys +import moe.apex.breadboard.prefs import moe.apex.breadboard.tag.IgnoredTagsHelper import moe.apex.breadboard.ui.theme.BreadboardTheme +import moe.apex.breadboard.ui.theme.prefTitle +import moe.apex.breadboard.util.BasicExpressiveContainer +import moe.apex.breadboard.util.CHIP_SPACING import moe.apex.breadboard.util.FollowingProvider import moe.apex.breadboard.util.FullscreenLoadingSpinner +import moe.apex.breadboard.util.LARGE_SPACER +import moe.apex.breadboard.util.ListItemPosition +import moe.apex.breadboard.util.MEDIUM_LARGE_SPACER +import moe.apex.breadboard.util.MEDIUM_SPACER import moe.apex.breadboard.util.MainScreenScaffold +import moe.apex.breadboard.util.RecommendationsHelper import moe.apex.breadboard.util.RecommendationsProvider import moe.apex.breadboard.util.SMALL_LARGE_SPACER import moe.apex.breadboard.util.SMALL_SPACER @@ -61,6 +77,7 @@ import moe.apex.breadboard.util.SmallTitleBar import moe.apex.breadboard.util.TINY_SPACER import moe.apex.breadboard.util.bottomAppBarAndNavBarHeight import moe.apex.breadboard.util.differenceOlderThan +import moe.apex.breadboard.util.filterChipSolidColor import moe.apex.breadboard.util.onScroll import moe.apex.breadboard.util.rememberPullToRefreshController import moe.apex.breadboard.util.saveIgnoreListWithTimestamp @@ -177,7 +194,8 @@ fun HomeScreen( val newProvider = FollowingProvider( followedArtists = prefs.followedTags, auth = prefs.authFor(ImageSource.DANBOORU, context), - showAllRatings = prefs.recommendAllRatings + showAllRatings = prefs.recommendAllRatings, + initialBlockedTags = prefs.blockedTags ) viewModel.setFollowingProvider(newProvider) } @@ -270,6 +288,7 @@ fun HomeScreen( val onRefresh: suspend () -> Unit by rememberUpdatedState { provider.let { + it.replaceBlockedTags(blockedTags) it.replaceFollowedArtists(prefs.followedTags) it.reset() it.loadMore() @@ -278,7 +297,8 @@ fun HomeScreen( } val ptrController = rememberPullToRefreshController( - enabled = provider.doneInitialLoad, + enabled = provider.images.isNotEmpty() || + (provider.doneInitialLoad && prefs.followedTags.isNotEmpty()), onRefresh = onRefresh ) @@ -305,21 +325,58 @@ fun HomeScreen( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally ) { - Text( - if (prefs.followedTags.isEmpty()){ - "You aren't following anyone yet." + if (prefs.followedTags.isEmpty()) { + val suggestedArtists = remember(prefs.favouriteImages, prefs.followedTags) { + RecommendationsHelper.getRecommendedArtists( + images = prefs.favouriteImages, + followedTags = prefs.followedTags + ) + } + + if (suggestedArtists.isNotEmpty()) { + BasicExpressiveContainer(position = ListItemPosition.SINGLE_ELEMENT) { + ArtistSuggestions( + suggestedArtists = suggestedArtists, + onFollow = { artists -> + /* I'm just doing these manually because for + some reason calling ptrController.refresh() + still treats the following list like it's + empty? */ + provider.reset() + provider.replaceFollowedArtists(artists.toSet()) + provider.replaceBlockedTags(blockedTags) + scope.launch { + context.prefs.updateSet( + key = PreferenceKeys.FOLLOWED_TAGS, + to = artists, + ) + provider.doneInitialLoad = false + provider.loadMore() + } + } + ) + } } else { - "No new posts from artists you follow." + Text( + text = "You aren't following anyone yet.", + modifier = Modifier.padding(top = LARGE_SPACER.dp) + ) } - ) - TextButton( - onClick = { - ptrController.refresh(animate = true) + } else { + Text( + text = "No new posts from artists you follow.", + modifier = Modifier.padding(top = LARGE_SPACER.dp) + ) + TextButton( + modifier = Modifier.padding(top = SMALL_SPACER.dp), + onClick = { + ptrController.refresh(animate = true) + } + ) { + Icon(Icons.Rounded.Refresh, contentDescription = null) + Spacer(Modifier.width(SMALL_SPACER.dp)) + Text("Refresh") } - ) { - Icon(Icons.Rounded.Refresh, contentDescription = null) - Spacer(Modifier.width(SMALL_SPACER.dp)) - Text("Refresh") } } }, @@ -359,6 +416,75 @@ fun HomeScreen( } +@Composable +private fun ArtistSuggestions( + suggestedArtists: List, + onFollow: (List) -> Unit +) { + val selectedForFollow = remember { mutableStateListOf() } + + Column( + modifier = Modifier + .fillMaxWidth() + .padding(MEDIUM_LARGE_SPACER.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(TINY_SPACER.dp) + ) { + Text( + style = MaterialTheme.typography.prefTitle, + color = MaterialTheme.colorScheme.primary, + text = "Welcome to your Following feed!", + ) + Text( + text = "Let's get started by following some of your favourite artists.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + + FlowRow( + modifier = Modifier.padding(vertical = SMALL_LARGE_SPACER.dp), + horizontalArrangement = Arrangement.spacedBy(CHIP_SPACING.dp, Alignment.CenterHorizontally), + ) { + for (artist in suggestedArtists) { + FilterChip( + selected = artist in selectedForFollow, + onClick = { + if (artist in selectedForFollow) { + selectedForFollow.remove(artist) + } else { + selectedForFollow.add(artist) + } + }, + label = { Text(artist) }, + colors = filterChipSolidColor, + border = null + ) + } + } + + Row( + horizontalArrangement = Arrangement.spacedBy(MEDIUM_SPACER.dp) + ) { + OutlinedButton( + onClick = { + onFollow(suggestedArtists) + } + ) { + Text("Follow all") + } + Button( + enabled = selectedForFollow.isNotEmpty(), + onClick = { + onFollow(selectedForFollow) + } + ) { + Text("Follow selected") + } + } + } +} + + @Composable private fun RowScope.TagPageIndicator( label: String, diff --git a/app/src/main/java/moe/apex/breadboard/util/FollowingProvider.kt b/app/src/main/java/moe/apex/breadboard/util/FollowingProvider.kt index 63273237..966f5818 100644 --- a/app/src/main/java/moe/apex/breadboard/util/FollowingProvider.kt +++ b/app/src/main/java/moe/apex/breadboard/util/FollowingProvider.kt @@ -19,12 +19,24 @@ class FollowingProvider( followedArtists: Set, private val auth: ImageBoardAuth?, private val showAllRatings: Boolean, + private val initialBlockedTags: Set, ) : GridStateHolder by GridStateHolderDelegate() { private val source = if (showAllRatings) Danbooru else DanbooruSafe private val mutableFollowedArtists = mutableStateSetOf().apply { addAll(followedArtists) } val followedArtists: Set get() = mutableFollowedArtists.toSet() + private val mutableBlockedTags = mutableStateSetOf().apply { addAll(initialBlockedTags) } + val blockedTags: Set + get() = mutableBlockedTags.toSet() + + fun replaceBlockedTags(tags: Set) { + Snapshot.withMutableSnapshot { + mutableBlockedTags.clear() + mutableBlockedTags.addAll(tags) + } + } + fun replaceFollowedArtists(artists: Set) { Snapshot.withMutableSnapshot { mutableFollowedArtists.clear() @@ -40,8 +52,11 @@ class FollowingProvider( private var shouldKeepSearching by mutableStateOf(true) suspend fun loadMore() { - if (isLoading || !shouldKeepSearching || followedArtists.isEmpty()) { - if (followedArtists.isEmpty()) doneInitialLoad = true + if (followedArtists.isEmpty()) { + doneInitialLoad = true + return images.clear() + } + if (isLoading || !shouldKeepSearching) { return } @@ -60,7 +75,11 @@ class FollowingProvider( page = pageNumber, auth = auth ) - allResults.addAll(results) + allResults.addAll( + elements = results.filter { + it.metadata!!.tags.none { tag -> blockedTags.contains(tag.lowercase()) } + } + ) } // Deduplicate and sort by ID descending 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 87cf5026..b7fa451b 100644 --- a/app/src/main/java/moe/apex/breadboard/util/RecommendationsHelper.kt +++ b/app/src/main/java/moe/apex/breadboard/util/RecommendationsHelper.kt @@ -163,6 +163,27 @@ object RecommendationsHelper { } + /** + * Recommends a set of artists based on the user's favorites, ranked by frequency. + * Artists already in [followedTags] are excluded. + */ + fun getRecommendedArtists( + images: List, + followedTags: Set, + limit: Int = 10 + ): List { + return images.flatMap { it.metadata?.artists ?: emptyList() } + .map { it.lowercase() } + .filter { it.isNotEmpty() && it !in followedTags } + .groupingBy { it } + .eachCount() + .entries + .sortedByDescending { it.value } + .take(limit) + .map { it.key } + } + + private fun pickWeightedTag( candidates: List, frequencies: Map, diff --git a/app/src/test/java/moe/apex/breadboard/util/RecommendationsHelperTest.kt b/app/src/test/java/moe/apex/breadboard/util/RecommendationsHelperTest.kt index c358f942..69383764 100644 --- a/app/src/test/java/moe/apex/breadboard/util/RecommendationsHelperTest.kt +++ b/app/src/test/java/moe/apex/breadboard/util/RecommendationsHelperTest.kt @@ -63,4 +63,38 @@ class RecommendationsHelperTest { ) assertTrue(recommended.isEmpty()) } + + @Test + fun testRecommendedArtists() { + fun createImageWithArtists(artists: List): Image { + return Image( + fileName = "test", + fileFormat = "jpg", + previewUrl = "", + fileUrl = "", + sampleUrl = "", + metadata = ImageMetadata( + rating = ImageRating.SAFE, + artists = artists + ) + ) + } + + val images = listOf( + createImageWithArtists(listOf("Artist A", "Artist B")), + createImageWithArtists(listOf("Artist A")), + createImageWithArtists(listOf("Artist C")), + createImageWithArtists(listOf("Artist A", "Artist C")) + ) + + val recommended = RecommendationsHelper.getRecommendedArtists( + images = images, + followedTags = setOf("artist b") + ) + + // Expected: Artist A (3), Artist C (2). Artist B is excluded. + assertTrue(recommended.size == 2) + assertTrue(recommended[0] == "artist a") + assertTrue(recommended[1] == "artist c") + } } From 03f77594ba96cb738bcbb73f259a10941966745b Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Thu, 2 Jul 2026 07:52:43 +0100 Subject: [PATCH 06/59] Remove one nesting layer --- .../apex/breadboard/navigation/Navigation.kt | 248 +++++++++--------- 1 file changed, 123 insertions(+), 125 deletions(-) diff --git a/app/src/main/java/moe/apex/breadboard/navigation/Navigation.kt b/app/src/main/java/moe/apex/breadboard/navigation/Navigation.kt index f4e25735..84b073bf 100644 --- a/app/src/main/java/moe/apex/breadboard/navigation/Navigation.kt +++ b/app/src/main/java/moe/apex/breadboard/navigation/Navigation.kt @@ -95,141 +95,139 @@ fun Navigation(navController: NavHostController, startDestination: Any = Search) } BreadboardTheme { - Surface { - Scaffold( - bottomBar = { - AnimatedVisibility( - visible = currentRoute.routeIs(topLevelScreens) && isNavigationBarVisible, - enter = slideInVertically { it /3} + fadeIn(), - exit = slideOutVertically { it/3 } + fadeOut() - ) { - NavigationBar(containerColor = BreadboardTheme.colors.titleBar) { - NavigationBarItem( - label = { Text("Browse") }, - selected = currentRoute.routeIs(Home::class), - icon = { - Icon( - painter = painterResource(if (currentRoute.routeIs(Home::class)) R.drawable.ic_home_filled else R.drawable.ic_home_hollow), - contentDescription = "Browse" - ) - }, - onClick = { - if (!currentRoute.routeIs(Home::class)) { - navController.navigate(Home) { - popUpTo(Home) { inclusive = true } - } + Scaffold( + bottomBar = { + AnimatedVisibility( + visible = currentRoute.routeIs(topLevelScreens) && isNavigationBarVisible, + enter = slideInVertically { it /3} + fadeIn(), + exit = slideOutVertically { it/3 } + fadeOut() + ) { + NavigationBar(containerColor = BreadboardTheme.colors.titleBar) { + NavigationBarItem( + label = { Text("Browse") }, + selected = currentRoute.routeIs(Home::class), + icon = { + Icon( + painter = painterResource(if (currentRoute.routeIs(Home::class)) R.drawable.ic_home_filled else R.drawable.ic_home_hollow), + contentDescription = "Browse" + ) + }, + onClick = { + if (!currentRoute.routeIs(Home::class)) { + navController.navigate(Home) { + popUpTo(Home) { inclusive = true } } } - ) - NavigationBarItem( - label = { Text("Search") }, - selected = currentRoute.routeIs(searchScreens), - icon = { - Icon( - imageVector = Icons.Rounded.Search, - contentDescription = "Search" - ) - }, - onClick = { - if (!currentRoute.routeIs(Search::class)) { - navController.navigate(Search) { - popUpTo(Search) { inclusive = true } - } - } else { - focusRequester.requestFocus() - keyboard?.show() /* Not technically necessary but allows the keyboard to appear - again if the user taps away while the search bar is still - focused */ + } + ) + NavigationBarItem( + label = { Text("Search") }, + selected = currentRoute.routeIs(searchScreens), + icon = { + Icon( + imageVector = Icons.Rounded.Search, + contentDescription = "Search" + ) + }, + onClick = { + if (!currentRoute.routeIs(Search::class)) { + navController.navigate(Search) { + popUpTo(Search) { inclusive = true } } + } else { + focusRequester.requestFocus() + keyboard?.show() /* Not technically necessary but allows the keyboard to appear + again if the user taps away while the search bar is still + focused */ } - ) - NavigationBarItem( - label = { Text("Favourites") }, - selected = currentRoute.routeIs(Favourites::class), - icon = { - Icon( - imageVector = if (currentRoute.routeIs(Favourites::class)) Icons.Rounded.Favorite else Icons.Rounded.FavoriteBorder, - contentDescription = "Favourite images" - ) - }, - onClick = { - if (!currentRoute.routeIs(Favourites::class)) { - navController.navigate(Favourites) { - popUpTo(Favourites) { inclusive = true } - } + } + ) + NavigationBarItem( + label = { Text("Favourites") }, + selected = currentRoute.routeIs(Favourites::class), + icon = { + Icon( + imageVector = if (currentRoute.routeIs(Favourites::class)) Icons.Rounded.Favorite else Icons.Rounded.FavoriteBorder, + contentDescription = "Favourite images" + ) + }, + onClick = { + if (!currentRoute.routeIs(Favourites::class)) { + navController.navigate(Favourites) { + popUpTo(Favourites) { inclusive = true } } } - ) - NavigationBarItem( - label = { Text("Settings") }, - selected = currentRoute.routeIs(settingsScreens), - icon = { - Icon( - painter = if (currentRoute.routeIs(settingsScreens)) rememberVectorPainter(Icons.Rounded.Settings) else painterResource(R.drawable.ic_settings_hollow), - contentDescription = "Settings" - ) - }, - onClick = { - if (currentRoute.routeIs(settingsScreens.filter { it != Settings::class })) { - navController.popBackStack() - } else if (!currentRoute.routeIs(Settings::class)) { - navController.navigate(Settings) { - popUpTo(Settings) { inclusive = true } - } + } + ) + NavigationBarItem( + label = { Text("Settings") }, + selected = currentRoute.routeIs(settingsScreens), + icon = { + Icon( + painter = if (currentRoute.routeIs(settingsScreens)) rememberVectorPainter(Icons.Rounded.Settings) else painterResource(R.drawable.ic_settings_hollow), + contentDescription = "Settings" + ) + }, + onClick = { + if (currentRoute.routeIs(settingsScreens.filter { it != Settings::class })) { + navController.popBackStack() + } else if (!currentRoute.routeIs(Settings::class)) { + navController.navigate(Settings) { + popUpTo(Settings) { inclusive = true } } } - ) - } + } + ) } } - ) { paddingValues -> - NavHost( - modifier = Modifier.padding(paddingValues.withoutVertical()), - navController = navController, - startDestination = startDestination, - enterTransition = { - if (targetState.destination.routeIs(slideTransitionScreens)) - enterTransition - else fadeIn() - }, - exitTransition = { - if (targetState.destination.routeIs(slideTransitionScreens)) - exitTransition - else fadeOut() - }, - popEnterTransition = { - if (initialState.destination.routeIs(slideTransitionScreens)) - popEnterTransition - else fadeIn() - }, - popExitTransition = { - if (initialState.destination.routeIs(slideTransitionScreens)) - popExitTransition - else fadeOut() - } - ) { - composable { - val args = it.toRoute() - LazyLargeImageView(navController, args.source, args.id, args.isMd5) - } - composable { HomeScreen(navController) { isNavigationBarVisible = it } } - composable { SearchScreen(navController, focusRequester) } - composable { - val args = it.toRoute() - SearchResults(navController, args.source, args.tags) - } - composable { FavouritesPage(navController) { isNavigationBarVisible = it } } - composable { PreferencesScreen(navController) } - composable { BlockedTagsScreen(navController) } - composable { LibrariesScreen(navController) } - composable { AboutScreen(navController) } - composable { ExperimentalScreen(navController) } - composable { RecommendationsSettingsScreen(navController) } - composable { IgnoredTagsScreen(navController) } - composable { - val args = it.toRoute() - ArtistProfileScreen(args.artistTag, navController = navController) - } + } + ) { paddingValues -> + NavHost( + modifier = Modifier.padding(paddingValues.withoutVertical()), + navController = navController, + startDestination = startDestination, + enterTransition = { + if (targetState.destination.routeIs(slideTransitionScreens)) + enterTransition + else fadeIn() + }, + exitTransition = { + if (targetState.destination.routeIs(slideTransitionScreens)) + exitTransition + else fadeOut() + }, + popEnterTransition = { + if (initialState.destination.routeIs(slideTransitionScreens)) + popEnterTransition + else fadeIn() + }, + popExitTransition = { + if (initialState.destination.routeIs(slideTransitionScreens)) + popExitTransition + else fadeOut() + } + ) { + composable { + val args = it.toRoute() + LazyLargeImageView(navController, args.source, args.id, args.isMd5) + } + composable { HomeScreen(navController) { isNavigationBarVisible = it } } + composable { SearchScreen(navController, focusRequester) } + composable { + val args = it.toRoute() + SearchResults(navController, args.source, args.tags) + } + composable { FavouritesPage(navController) { isNavigationBarVisible = it } } + composable { PreferencesScreen(navController) } + composable { BlockedTagsScreen(navController) } + composable { LibrariesScreen(navController) } + composable { AboutScreen(navController) } + composable { ExperimentalScreen(navController) } + composable { RecommendationsSettingsScreen(navController) } + composable { IgnoredTagsScreen(navController) } + composable { + val args = it.toRoute() + ArtistProfileScreen(args.artistTag, navController = navController) } } } From b239ed336fbaae64010f5742ad02526500cb68e5 Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Thu, 2 Jul 2026 09:04:26 +0100 Subject: [PATCH 07/59] Add pref for default browse tab --- .../moe/apex/breadboard/home/HomeScreen.kt | 45 +++++++++++++------ .../moe/apex/breadboard/preferences/Pref.kt | 18 ++++++-- .../breadboard/preferences/Preferences.kt | 17 +++++++ .../apex/breadboard/viewmodel/ViewModel.kt | 8 ++++ 4 files changed, 71 insertions(+), 17 deletions(-) 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 1faffe45..3bb13d97 100644 --- a/app/src/main/java/moe/apex/breadboard/home/HomeScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/home/HomeScreen.kt @@ -30,6 +30,7 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf @@ -54,6 +55,7 @@ import moe.apex.breadboard.preferences.Experiment import moe.apex.breadboard.preferences.ImageSource import moe.apex.breadboard.preferences.LocalPreferences import moe.apex.breadboard.largeimageview.OffsetBasedLargeImageView +import moe.apex.breadboard.preferences.BrowseTab import moe.apex.breadboard.preferences.PreferenceKeys import moe.apex.breadboard.prefs import moe.apex.breadboard.tag.IgnoredTagsHelper @@ -95,6 +97,7 @@ fun HomeScreen( val context = LocalContext.current val prefs = LocalPreferences.current val viewModel = getGlobalViewModel() + val defaultTab by viewModel.defaultBrowseTab.collectAsState() val recommendationsProvider by viewModel.recommendationsProvider.collectAsState() val followingProvider by viewModel.followingProvider.collectAsState() val blockedTags by rememberUpdatedState(prefs.blockedTags) @@ -105,7 +108,19 @@ fun HomeScreen( var shouldShowLargeImage by remember { mutableStateOf(false) } var selectedImageIndex by remember { mutableIntStateOf(0) } - val pagerState = rememberPagerState { 2 } + val pagerState = rememberPagerState( + initialPage = defaultTab?.let { BrowseTab.entries.indexOf(it) } ?: run { + val t = prefs.defaultBrowseTab + viewModel.setDefaultBrowseTab(t) + BrowseTab.entries.indexOf(t) + }, + ) { + BrowseTab.entries.size + } + + SideEffect(pagerState.currentPage) { + viewModel.setDefaultBrowseTab(BrowseTab.entries[pagerState.currentPage]) + } val blur = prefs.isExperimentEnabled(Experiment.IMMERSIVE_UI_EFFECTS) @@ -116,7 +131,11 @@ fun HomeScreen( title = "Breadboard", additionalActions = { val currentProvider = - if (pagerState.currentPage == 0) recommendationsProvider else followingProvider + if (pagerState.currentPage == BrowseTab.entries.indexOf(BrowseTab.FOR_YOU)) { + recommendationsProvider + } else { + followingProvider + } currentProvider?.let { ScrollToTopArrow( scrollableState = if (prefs.useStaggeredGrid) it.staggeredGridState else it.uniformGridState, @@ -137,16 +156,13 @@ fun HomeScreen( .padding(horizontal = SMALL_LARGE_SPACER.dp, vertical = TINY_SPACER.dp), horizontalArrangement = Arrangement.spacedBy(SMALL_SPACER.dp) ) { - TagPageIndicator( - label = "For You", - selected = pagerState.currentPage == 0, - onClick = { scope.launch { pagerState.animateScrollToPage(0) } } - ) - TagPageIndicator( - label = "Following", - selected = pagerState.currentPage == 1, - onClick = { scope.launch { pagerState.animateScrollToPage(1) } } - ) + BrowseTab.entries.forEachIndexed { index, tab -> + TagPageIndicator( + label = tab.label, + selected = pagerState.currentPage == index, + onClick = { scope.launch { pagerState.animateScrollToPage(index) } } + ) + } } } }, @@ -204,6 +220,7 @@ fun HomeScreen( HorizontalPager( state = pagerState, + key = { BrowseTab.entries[it].name }, modifier = Modifier .fillMaxSize() .padding(padding), @@ -211,7 +228,7 @@ fun HomeScreen( verticalAlignment = Alignment.Top, userScrollEnabled = !shouldShowLargeImage ) { page -> - if (page == 0) { + if (page == BrowseTab.entries.indexOf(BrowseTab.FOR_YOU)) { recommendationsProvider?.let { provider -> val state = if (prefs.useStaggeredGrid) { provider.staggeredGridState @@ -278,7 +295,7 @@ fun HomeScreen( onEndReached = { provider.recommendImages() }, ) } - } else { + } else if (page == BrowseTab.entries.indexOf(BrowseTab.FOLLOWING)) { followingProvider?.let { provider -> val state = if (prefs.useStaggeredGrid) { provider.staggeredGridState 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 a5ec572b..65d55635 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/Pref.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/Pref.kt @@ -98,6 +98,7 @@ data object PrefNames { const val AUTOPLAY_VIDEOS = "autoplay_videos" const val UNIFIED_INFO_SHEET = "unified_info_sheet" const val FOLLOWED_TAGS = "followed_tags" + const val DEFAULT_BROWSE_TAB = "default_browse_tab" } @@ -132,6 +133,7 @@ object PreferenceKeys { val AUTOPLAY_VIDEOS = stringPreferencesKey(PrefNames.AUTOPLAY_VIDEOS) val UNIFIED_INFO_SHEET = booleanPreferencesKey(PrefNames.UNIFIED_INFO_SHEET) val FOLLOWED_TAGS = stringSetPreferencesKey(PrefNames.FOLLOWED_TAGS) + val DEFAULT_BROWSE_TAB = stringPreferencesKey(PrefNames.DEFAULT_BROWSE_TAB) } @@ -185,6 +187,11 @@ enum class AutoplayVideosMode(override val label: String) : PrefEnum { + FOR_YOU("For You"), + FOLLOWING("Following") +} + enum class Experiment(override val label: String, val description: String? = null) : PrefEnum { ALWAYS_ANIMATE_SCROLL("Always animate scroll-to-top", "Enable smooth scrolling on all pages when using the scroll-to-top button."), IMMERSIVE_UI_EFFECTS("Immersive UI effects", "Enables immersive UI effects such as blur and translucency. Not all devices support this feature. Disable if you experience poor performance."), @@ -229,7 +236,8 @@ data class Prefs( val internalIgnoreList: Set, val autoplayVideos: AutoplayVideosMode, val unifiedInfoSheet: Boolean, - val followedTags: Set + val followedTags: Set, + val defaultBrowseTab: BrowseTab, ) { companion object { val DEFAULT = Prefs( @@ -262,7 +270,8 @@ data class Prefs( internalIgnoreList = emptySet(), autoplayVideos = AutoplayVideosMode.OFF, unifiedInfoSheet = false, // Unified is called 'Classic' in the UI - followedTags = emptySet() + followedTags = emptySet(), + defaultBrowseTab = BrowseTab.FOR_YOU, ) } @@ -326,7 +335,8 @@ class UserPreferencesRepository(private val dataStore: DataStore) { 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), - PreferenceKeys.FOLLOWED_TAGS to PrefMeta(PrefCategory.SETTING, mergeable = true) + PreferenceKeys.FOLLOWED_TAGS to PrefMeta(PrefCategory.SETTING, mergeable = true), + PreferenceKeys.DEFAULT_BROWSE_TAB to PrefMeta(PrefCategory.SETTING) ) } @@ -825,6 +835,7 @@ class UserPreferencesRepository(private val dataStore: DataStore) { val autoplayVideos = preferences[PreferenceKeys.AUTOPLAY_VIDEOS]?.let { AutoplayVideosMode.valueOf(it) } ?: Prefs.DEFAULT.autoplayVideos val unifiedInfoSheet = preferences[PreferenceKeys.UNIFIED_INFO_SHEET] ?: Prefs.DEFAULT.unifiedInfoSheet val followedTags = preferences[PreferenceKeys.FOLLOWED_TAGS] ?: Prefs.DEFAULT.followedTags + val defaultBrowseTab = preferences[PreferenceKeys.DEFAULT_BROWSE_TAB]?.let { BrowseTab.valueOf(it) } ?: Prefs.DEFAULT.defaultBrowseTab return Prefs( dataSaver, @@ -857,6 +868,7 @@ class UserPreferencesRepository(private val dataStore: DataStore) { autoplayVideos, unifiedInfoSheet, followedTags, + defaultBrowseTab, ) } } 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 aeecc896..95cb776c 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/Preferences.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/Preferences.kt @@ -468,6 +468,23 @@ fun PreferencesScreen(navController: NavHostController) { } } } + /* It's an enum so EnumPref would be the "correct" implementation, + but I think this is a better UX as there are currently only two options. */ + item { + SwitchPref( + checked = currentSettings.defaultBrowseTab == BrowseTab.FOLLOWING, + title = "Prefer Following tab", + summary = "By default, open the Following tab instead of the For You " + + "tab on the Browse page.", + ) { following -> + scope.launch { + preferencesRepository.updatePref( + PreferenceKeys.DEFAULT_BROWSE_TAB, + if (following) BrowseTab.FOLLOWING else BrowseTab.FOR_YOU + ) + } + } + } item { EnumPref( title = "Hide app content", diff --git a/app/src/main/java/moe/apex/breadboard/viewmodel/ViewModel.kt b/app/src/main/java/moe/apex/breadboard/viewmodel/ViewModel.kt index b2dbcbf4..50f02309 100644 --- a/app/src/main/java/moe/apex/breadboard/viewmodel/ViewModel.kt +++ b/app/src/main/java/moe/apex/breadboard/viewmodel/ViewModel.kt @@ -17,6 +17,7 @@ import moe.apex.breadboard.util.FollowingProvider import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.lifecycle.viewmodel.compose.viewModel +import moe.apex.breadboard.preferences.BrowseTab object GlobalViewModelOwner : ViewModelStoreOwner { @@ -46,6 +47,9 @@ class BreadboardViewModel : ViewModel() { private val _imageHdQualityOverrides = MutableStateFlow>(emptyMap()) val imageHdQualityOverrides: StateFlow> = _imageHdQualityOverrides.asStateFlow() + private val _defaultBrowseTab = MutableStateFlow(null) + val defaultBrowseTab: StateFlow = _defaultBrowseTab.asStateFlow() + fun setRecommendationsProvider(provider: RecommendationsProvider?) { _recommendationsProvider.value = provider } @@ -67,6 +71,10 @@ class BreadboardViewModel : ViewModel() { _userMutePreference.value = muted } + fun setDefaultBrowseTab(browseTab: BrowseTab) { + _defaultBrowseTab.value = browseTab + } + @Composable fun rememberImageHdQualityPreference(image: Image, dataSaver: DataSaver, defaultValue: Boolean): Boolean { val overrides by imageHdQualityOverrides.collectAsState() From 9d8b1f9fab5768288a62038838e96fdbaa985055 Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Thu, 2 Jul 2026 09:04:53 +0100 Subject: [PATCH 08/59] import --- app/src/main/java/moe/apex/breadboard/navigation/Navigation.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/java/moe/apex/breadboard/navigation/Navigation.kt b/app/src/main/java/moe/apex/breadboard/navigation/Navigation.kt index 84b073bf..eaa9e637 100644 --- a/app/src/main/java/moe/apex/breadboard/navigation/Navigation.kt +++ b/app/src/main/java/moe/apex/breadboard/navigation/Navigation.kt @@ -19,7 +19,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect From 0d21d6adadb923a7cdbbdf1e5ebde171a34c08e1 Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Fri, 3 Jul 2026 18:17:33 +0100 Subject: [PATCH 09/59] Settings reorg and minor cleanup --- .../moe/apex/breadboard/home/HomeScreen.kt | 17 +- .../breadboard/largeimageview/InfoSheet.kt | 7 +- .../breadboard/navigation/Destinations.kt | 12 + .../apex/breadboard/navigation/Navigation.kt | 22 +- .../preferences/ContentSettingsScreen.kt | 143 ++++ .../preferences/DataSettingsScreen.kt | 225 +++++ .../preferences/ExperimentalScreen.kt | 93 ++- .../preferences/GeneralSettingsScreen.kt | 171 ++++ .../preferences/LayoutSettingsScreen.kt | 171 ++++ .../breadboard/preferences/Preferences.kt | 773 +++--------------- .../breadboard/preferences/PreferencesUI.kt | 165 ++++ .../java/moe/apex/breadboard/ui/theme/Type.kt | 6 +- .../moe/apex/breadboard/util/PrefUtils.kt | 14 +- .../main/java/moe/apex/breadboard/util/Ui.kt | 37 +- 14 files changed, 1123 insertions(+), 733 deletions(-) create mode 100644 app/src/main/java/moe/apex/breadboard/preferences/ContentSettingsScreen.kt create mode 100644 app/src/main/java/moe/apex/breadboard/preferences/DataSettingsScreen.kt create mode 100644 app/src/main/java/moe/apex/breadboard/preferences/GeneralSettingsScreen.kt create mode 100644 app/src/main/java/moe/apex/breadboard/preferences/LayoutSettingsScreen.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 3bb13d97..42d5c18c 100644 --- a/app/src/main/java/moe/apex/breadboard/home/HomeScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/home/HomeScreen.kt @@ -2,6 +2,7 @@ package moe.apex.breadboard.home import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row @@ -10,6 +11,7 @@ import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.pager.HorizontalPager @@ -18,8 +20,10 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon +import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text @@ -64,7 +68,6 @@ import moe.apex.breadboard.ui.theme.prefTitle import moe.apex.breadboard.util.BasicExpressiveContainer import moe.apex.breadboard.util.CHIP_SPACING import moe.apex.breadboard.util.FollowingProvider -import moe.apex.breadboard.util.FullscreenLoadingSpinner import moe.apex.breadboard.util.LARGE_SPACER import moe.apex.breadboard.util.ListItemPosition import moe.apex.breadboard.util.MEDIUM_LARGE_SPACER @@ -87,7 +90,7 @@ import moe.apex.breadboard.viewmodel.getGlobalViewModel import kotlin.time.Duration.Companion.days -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun HomeScreen( navController: NavController, @@ -186,7 +189,15 @@ fun HomeScreen( } if (builtInIgnoredTags.isEmpty()) { - FullscreenLoadingSpinner() + Box( + modifier = Modifier + .fillMaxWidth() + .padding(padding) + .offset(y = SMALL_LARGE_SPACER.dp), + contentAlignment = Alignment.TopCenter + ) { + LoadingIndicator() + } } else { if (recommendationsProvider == null) { LaunchedEffect(Unit) { 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 1bed6ce2..09e28445 100644 --- a/app/src/main/java/moe/apex/breadboard/largeimageview/InfoSheet.kt +++ b/app/src/main/java/moe/apex/breadboard/largeimageview/InfoSheet.kt @@ -95,6 +95,7 @@ import moe.apex.breadboard.util.MEDIUM_SPACER import moe.apex.breadboard.util.LazyExpressiveGroup import moe.apex.breadboard.util.SMALL_LARGE_SPACER import moe.apex.breadboard.util.TINY_SPACER +import moe.apex.breadboard.util.TITLE_SUMMARY_VERTICAL_SPACING import moe.apex.breadboard.util.TitleSummary import moe.apex.breadboard.util.TitledModalBottomSheet import moe.apex.breadboard.util.bouncyAnimationSpec @@ -623,7 +624,7 @@ private fun LazyListScope.infoContentItems( // In unified mode, these are displayed on the first page instead of this one. if (!unified) { - LazyExpressiveGroup(useBox = true) { + LazyExpressiveGroup(clip = false) { mainTagsItems(image, onTagClick, onTagLongClick) } } @@ -653,7 +654,7 @@ private fun LazyListScope.imageboardDataContentItems( } } - LazyExpressiveGroup(useBox = true) { + LazyExpressiveGroup(clip = false) { if (unified) { mainTagsItems(image, onTagClick, onTagLongClick) } @@ -761,7 +762,7 @@ private fun TagsContainer( Modifier .fillMaxWidth() .padding( - top = SMALL_LARGE_SPACER.dp, + top = TITLE_SUMMARY_VERTICAL_SPACING.dp, bottom = (SMALL_LARGE_SPACER - 8).dp, // Chips have 8dp vertical padding already start = SMALL_LARGE_SPACER.dp, end = SMALL_LARGE_SPACER.dp diff --git a/app/src/main/java/moe/apex/breadboard/navigation/Destinations.kt b/app/src/main/java/moe/apex/breadboard/navigation/Destinations.kt index 128b4a5e..7d83e53b 100644 --- a/app/src/main/java/moe/apex/breadboard/navigation/Destinations.kt +++ b/app/src/main/java/moe/apex/breadboard/navigation/Destinations.kt @@ -96,6 +96,18 @@ object Favourites @Serializable object Settings +@Serializable +object GeneralSettings + +@Serializable +object ContentSettings + +@Serializable +object LayoutSettings + +@Serializable +object DataSettings + @Serializable object BlockedTagsSettings diff --git a/app/src/main/java/moe/apex/breadboard/navigation/Navigation.kt b/app/src/main/java/moe/apex/breadboard/navigation/Navigation.kt index eaa9e637..98a5135b 100644 --- a/app/src/main/java/moe/apex/breadboard/navigation/Navigation.kt +++ b/app/src/main/java/moe/apex/breadboard/navigation/Navigation.kt @@ -49,8 +49,12 @@ import moe.apex.breadboard.home.HomeScreen import moe.apex.breadboard.largeimageview.LazyLargeImageView import moe.apex.breadboard.preferences.AboutScreen import moe.apex.breadboard.preferences.BlockedTagsScreen +import moe.apex.breadboard.preferences.ContentSettingsScreen +import moe.apex.breadboard.preferences.DataSettingsScreen import moe.apex.breadboard.preferences.ExperimentalScreen +import moe.apex.breadboard.preferences.GeneralSettingsScreen import moe.apex.breadboard.preferences.IgnoredTagsScreen +import moe.apex.breadboard.preferences.LayoutSettingsScreen import moe.apex.breadboard.preferences.LibrariesScreen import moe.apex.breadboard.preferences.PreferencesScreen import moe.apex.breadboard.preferences.RecommendationsSettingsScreen @@ -81,7 +85,19 @@ fun Navigation(navController: NavHostController, startDestination: Any = Search) val popEnterTransition = slideInHorizontally(tween(easing = easing)) { -slideDistance } + fadeIn(tween(easing = easing)) val searchScreens = listOf(Search::class, Results::class, ArtistProfile::class) - val settingsScreens = listOf(Settings::class, BlockedTagsSettings::class, AboutSettings::class, LibrariesSettings::class, ExperimentalSettings::class, RecommendationsSettings::class, IgnoredTagsSettings::class) + val settingsScreens = listOf( + Settings::class, + GeneralSettings::class, + ContentSettings::class, + LayoutSettings::class, + DataSettings::class, + BlockedTagsSettings::class, + AboutSettings::class, + LibrariesSettings::class, + ExperimentalSettings::class, + RecommendationsSettings::class, + IgnoredTagsSettings::class + ) val topLevelScreens = listOf(Home::class, Search::class, Favourites::class) + settingsScreens val slideTransitionScreens = listOf(Results::class, ImageView::class, ArtistProfile::class, *settingsScreens.filter { it != Settings::class }.toTypedArray()) @@ -218,6 +234,10 @@ fun Navigation(navController: NavHostController, startDestination: Any = Search) } composable { FavouritesPage(navController) { isNavigationBarVisible = it } } composable { PreferencesScreen(navController) } + composable { GeneralSettingsScreen(navController) } + composable { ContentSettingsScreen(navController) } + composable { LayoutSettingsScreen(navController) } + composable { DataSettingsScreen(navController) } composable { BlockedTagsScreen(navController) } composable { LibrariesScreen(navController) } composable { AboutScreen(navController) } diff --git a/app/src/main/java/moe/apex/breadboard/preferences/ContentSettingsScreen.kt b/app/src/main/java/moe/apex/breadboard/preferences/ContentSettingsScreen.kt new file mode 100644 index 00000000..9a0d3b1f --- /dev/null +++ b/app/src/main/java/moe/apex/breadboard/preferences/ContentSettingsScreen.kt @@ -0,0 +1,143 @@ +package moe.apex.breadboard.preferences + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +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 +import androidx.compose.runtime.rememberCoroutineScope +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.navigation.NavHostController +import kotlinx.coroutines.launch +import moe.apex.breadboard.navigation.BlockedTagsSettings +import moe.apex.breadboard.navigation.RecommendationsSettings +import moe.apex.breadboard.prefs +import moe.apex.breadboard.util.ChevronRight +import moe.apex.breadboard.util.LargeTitleBar +import moe.apex.breadboard.util.LazyExpressiveGroup +import moe.apex.breadboard.util.MainScreenScaffold +import moe.apex.breadboard.util.MEDIUM_SPACER +import moe.apex.breadboard.util.TitleSummary +import moe.apex.breadboard.viewmodel.getGlobalViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ContentSettingsScreen(navController: NavHostController) { + val topAppBarState = rememberTopAppBarState() + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(topAppBarState) + val viewModel = getGlobalViewModel() + val scope = rememberCoroutineScope() + val preferencesRepository = LocalContext.current.prefs + val currentSettings = LocalPreferences.current + + MainScreenScaffold( + topAppBar = { + LargeTitleBar( + title = "Content", + scrollBehavior = scrollBehavior, + navController = navController + ) + } + ) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(it) + .nestedScroll(scrollBehavior.nestedScrollConnection), + contentPadding = PaddingValues(MEDIUM_SPACER.dp), + ) { + LazyExpressiveGroup(desiredTopPadding = null) { + item { + TitleSummary( + modifier = Modifier.fillMaxWidth(), + title = "Manage blocked tags", + summary = "Add or remove tags to block from search results and recommendations.", + trailingIcon = { ChevronRight() } + ) { + navController.navigate(BlockedTagsSettings) + } + } + item { + TitleSummary( + modifier = Modifier.fillMaxWidth(), + title = "Manage recommendations", + summary = "Fine-tune your recommendations by customising which tags can be used to recommend new content.", + trailingIcon = { ChevronRight() } + ) { + navController.navigate(RecommendationsSettings) + } + } + item { + SwitchPref( + checked = currentSettings.excludeAi, + title = "Hide AI-generated images", + summary = "Attempt to hide AI-generated images by automatically adding " + + "AI-related tags to your block list." + ) { + scope.launch { + preferencesRepository.updatePref( + PreferenceKeys.EXCLUDE_AI, + it + ) + } + viewModel.resetProviders() + } + } + item { + EnumPref( + title = "Automatically play videos", + summary = currentSettings.autoplayVideos.label, + infoText = "Control whether videos will automatically play when " + + "opening them.\n\n" + + "When this is enabled, videos will start muted until you " + + "manually unmute by tapping the icon or your device's " + + "volume up button.", + enumItems = AutoplayVideosMode.entries, + selectedItem = currentSettings.autoplayVideos, + onSelection = { + scope.launch { + preferencesRepository.updatePref( + PreferenceKeys.AUTOPLAY_VIDEOS, + it + ) + } + } + ) + } + } + + LazyExpressiveGroup("Advanced") { + item { + SwitchPref( + checked = currentSettings.filterRatingsLocally, + title = "Filter ratings locally", + summary = "Rather than appending the selected ratings to the search query, " + + "filter the results by rating after searching.", + infoText = "Danbooru limits searches to 2 tags " + + "(which includes ratings) without an API key.\n\n" + + "Enabling this option will allow you to filter by rating " + + "on all sources without an API key, and also let you filter " + + "ratings mid-search, but may cause less results to be shown " + + "at once, resulting in slightly higher data usage.\n\n" + + "Yande.re always requires this option.\n\n" + + "If you're unsure, keep this option enabled." + ) { + scope.launch { + preferencesRepository.updatePref( + PreferenceKeys.FILTER_RATINGS_LOCALLY, + it + ) + } + } + } + } + } + } +} diff --git a/app/src/main/java/moe/apex/breadboard/preferences/DataSettingsScreen.kt b/app/src/main/java/moe/apex/breadboard/preferences/DataSettingsScreen.kt new file mode 100644 index 00000000..d19dbed3 --- /dev/null +++ b/app/src/main/java/moe/apex/breadboard/preferences/DataSettingsScreen.kt @@ -0,0 +1,225 @@ +package moe.apex.breadboard.preferences + +import android.net.Uri +import android.provider.OpenableColumns +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +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 +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.toMutableStateList +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.navigation.NavHostController +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import moe.apex.breadboard.prefs +import moe.apex.breadboard.util.ExportDirectoryHandler +import moe.apex.breadboard.util.ImportException +import moe.apex.breadboard.util.ImportHandler +import moe.apex.breadboard.util.LargeTitleBar +import moe.apex.breadboard.util.LazyExpressiveGroup +import moe.apex.breadboard.util.MainScreenScaffold +import moe.apex.breadboard.util.MEDIUM_SPACER +import moe.apex.breadboard.util.PromptType +import moe.apex.breadboard.util.StorageLocationSelection +import moe.apex.breadboard.util.TitleSummary +import moe.apex.breadboard.util.exportData +import moe.apex.breadboard.util.importData +import moe.apex.breadboard.util.preImportChecks +import moe.apex.breadboard.util.saveUriToPref +import moe.apex.breadboard.util.showToast +import org.json.JSONException +import org.json.JSONObject +import java.io.FileInputStream + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DataSettingsScreen(navController: NavHostController) { + val topAppBarState = rememberTopAppBarState() + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(topAppBarState) + val context = LocalContext.current + val scope = rememberCoroutineScope() + + var storageLocationPromptLaunched by remember { mutableStateOf(false) } + + val currentSettings = LocalPreferences.current + + if (storageLocationPromptLaunched) { + StorageLocationSelection( + promptType = PromptType.DIRECTORY_PERMISSION, + onFailure = { storageLocationPromptLaunched = false } + ) { uri -> + saveUriToPref(context, scope, uri) + storageLocationPromptLaunched = false + } + } + + var showExportDialog by remember { mutableStateOf(false) } + var exportedData: JSONObject? by remember { mutableStateOf(null) } + var importedData: JSONObject? by remember { mutableStateOf(null) } + var importingStarted by rememberSaveable { mutableStateOf(false) } + + if (showExportDialog) { + val categories = remember { PrefCategory.entries.toMutableStateList() } + ExportDialog( + enabledCategories = categories, + onDismissRequest = { showExportDialog = false } + ) { + scope.launch { + exportedData = exportData(context, categories) + showExportDialog = false + } + } + } + + if (exportedData != null) { + ExportDirectoryHandler(exportedData!!) { exportedData = null } + } + + if (importingStarted) { + ImportHandler({ importingStarted = false }) { uri -> + var displayName = uri.lastPathSegment.toString() + if (!displayName.endsWith(".bread")) { + /* Android's content provider also sometimes returns URIs that hide the real file + path and name so we need to query the content resolver to get the correct one. */ + context.contentResolver.query(uri, null, null, null).use { cursor -> + if (cursor?.moveToFirst() == true) { + val displayNameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) + if (displayNameIndex != -1) { + displayName = cursor.getString(displayNameIndex) + } + } + } + } + if (!displayName.endsWith(".bread")) { + showToast(context, "Invalid file type.") + importingStarted = false + return@ImportHandler + } + + scope.launch(Dispatchers.IO) { + context.contentResolver.openFileDescriptor(uri, "r").use { fd -> + FileInputStream(fd!!.fileDescriptor).use { fis -> + try { + val json = JSONObject(fis.readBytes().decodeToString()) + val checkResult = preImportChecks(currentSettings, json) + if (!checkResult.isSuccess) { + throw checkResult.exceptionOrNull()!! + } + importedData = json + } catch (e: Exception) { + withContext(Dispatchers.Main) { + showToast( + context = context, + text = when (e) { + is JSONException -> "Invalid backup provided." + is ImportException -> e.message!! + else -> "Unknown error." + } + ) + } + } + } + } + importingStarted = false + } + } + } + + if (importedData != null) { + ImportDialog( + allowedCategories = PrefCategory.entries.filter { pc -> pc == PrefCategory.BUILD || pc.name in importedData!! }, + onDismissRequest = { importedData = null} + ) { categories, merge -> + scope.launch { + val result = importData(context, importedData!!, categories, merge) + withContext(Dispatchers.Main) { + if (result.isFailure) { + showToast(context, result.exceptionOrNull()!!.message!!) + } else { + showToast(context, "Imported successfully.") + } + } + importedData = null + } + } + } + + MainScreenScaffold( + topAppBar = { + LargeTitleBar( + title = "Data and storage", + scrollBehavior = scrollBehavior, + navController = navController + ) + } + ) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(it) + .nestedScroll(scrollBehavior.nestedScrollConnection), + contentPadding = PaddingValues(MEDIUM_SPACER.dp) + ) { + LazyExpressiveGroup(desiredTopPadding = null) { + item { + TitleSummary( + modifier = Modifier.fillMaxWidth(), + title = "Save downloads to", + summary = if (currentSettings.storageLocation == Uri.EMPTY) "Tap to set" + else currentSettings.storageLocation.toString() + ) { + storageLocationPromptLaunched = true + } + } + } + + LazyExpressiveGroup("Your Breadboard data") { + item { + TitleSummary( + modifier = Modifier.fillMaxWidth(), + title = "Export data", + summary = "Export a backup file containing your current settings, favourite images, " + + "and search history. Downloads are not included." + ) { + showExportDialog = true + } + } + item { + TitleSummary( + modifier = Modifier.fillMaxWidth(), + title = "Import data", + summary = "Import a Breadboard backup file." + ) { + importingStarted = true + } + } + } + } + } +} + + +private operator fun JSONObject.contains(key: String): Boolean { + return try { + get(key) + true + } catch (_: JSONException) { + false + } +} + 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..fbc2635e 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/ExperimentalScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/ExperimentalScreen.kt @@ -1,7 +1,6 @@ package moe.apex.breadboard.preferences import android.os.Build -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding @@ -18,21 +17,24 @@ import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController import kotlinx.coroutines.launch import moe.apex.breadboard.prefs -import moe.apex.breadboard.util.ExpressiveGroup -import moe.apex.breadboard.util.LARGE_SPACER import moe.apex.breadboard.util.LargeTitleBar +import moe.apex.breadboard.util.LazyExpressiveGroup import moe.apex.breadboard.util.MainScreenScaffold import moe.apex.breadboard.util.MEDIUM_SPACER -import moe.apex.breadboard.util.SMALL_LARGE_SPACER import moe.apex.breadboard.util.Summary +import moe.apex.breadboard.util.TINY_SPACER import moe.apex.breadboard.util.TitleSummary +import moe.apex.breadboard.util.saveIgnoreListWithTimestamp import moe.apex.breadboard.util.showToast +import moe.apex.breadboard.viewmodel.getGlobalViewModel +import kotlin.collections.emptySet @OptIn(ExperimentalMaterial3Api::class) @Composable fun ExperimentalScreen(navController: NavHostController) { val context = LocalContext.current + val viewModel = getGlobalViewModel() val preferencesRepository = context.prefs val scope = rememberCoroutineScope() val topAppBarState = rememberTopAppBarState() @@ -52,12 +54,11 @@ fun ExperimentalScreen(navController: NavHostController) { .fillMaxSize() .padding(it) .nestedScroll(scrollBehavior.nestedScrollConnection), - contentPadding = PaddingValues(vertical = MEDIUM_SPACER.dp), - verticalArrangement = Arrangement.spacedBy(LARGE_SPACER.dp) + contentPadding = PaddingValues(MEDIUM_SPACER.dp) ) { item { Summary( - modifier = Modifier.padding(horizontal = SMALL_LARGE_SPACER.dp), + modifier = Modifier.padding(horizontal = TINY_SPACER.dp), text = "These features are incomplete and might have bugs. " + "Future updates may enable an experimental feature by default " + "(at which point it will be removed from this page), or they may " + @@ -66,23 +67,21 @@ fun ExperimentalScreen(navController: NavHostController) { ) } - item { - ExpressiveGroup { - for (experiment in Experiment.entries) { - item { - SwitchPref( - title = experiment.label, - summary = experiment.description, - checked = experiment.isEnabled(), - // Immersive carousel uses a blur modifier which requires Android 12+ to work - enabled = experiment != Experiment.IMMERSIVE_UI_EFFECTS || Build.VERSION.SDK_INT >= Build.VERSION_CODES.S, - ) { - scope.launch { - if (it) { - preferencesRepository.addToSet(PreferenceKeys.ENABLED_EXPERIMENTS, experiment) - } else { - preferencesRepository.removeFromSet(PreferenceKeys.ENABLED_EXPERIMENTS, experiment) - } + LazyExpressiveGroup { + for (experiment in Experiment.entries) { + item { + SwitchPref( + title = experiment.label, + summary = experiment.description, + checked = experiment.isEnabled(), + // Immersive carousel uses a blur modifier which requires Android 12+ to work + enabled = experiment != Experiment.IMMERSIVE_UI_EFFECTS || Build.VERSION.SDK_INT >= Build.VERSION_CODES.S, + ) { + scope.launch { + if (it) { + preferencesRepository.addToSet(PreferenceKeys.ENABLED_EXPERIMENTS, experiment) + } else { + preferencesRepository.removeFromSet(PreferenceKeys.ENABLED_EXPERIMENTS, experiment) } } } @@ -90,20 +89,38 @@ fun ExperimentalScreen(navController: NavHostController) { } } - item { - ExpressiveGroup("Debug") { - item { - TitleSummary( - title = "Reset age verification", - summary = "This will not disable any currently enabled age-gated " + - "features, but you will need to re-verify if you manually " + - "disable and enable them again." - ) { - scope.launch { - preferencesRepository.updatePref(PreferenceKeys.HAS_VERIFIED_AGE, false) - }.invokeOnCompletion { - showToast(context, "Done") - } + LazyExpressiveGroup("Debug") { + item { + TitleSummary( + title = "Reset age verification", + summary = "This will not disable any currently enabled age-gated " + + "features, but you will need to re-verify if you manually " + + "disable and enable them again." + ) { + scope.launch { + preferencesRepository.updatePref(PreferenceKeys.HAS_VERIFIED_AGE, false) + }.invokeOnCompletion { + showToast(context, "Done") + } + } + } + + item { + TitleSummary( + title = "Clear internal ignore list", + summary = "Clear Breadboard's internal ignored tag list. " + + "This will trigger a slightly longer refresh of your " + + "recommendations on your next visit." + ) { + viewModel.resetProviders() + scope.launch { + saveIgnoreListWithTimestamp( + context = context, + data = emptySet(), + timestamp = 0L + ) + }.invokeOnCompletion { + showToast(context, "Done") } } } diff --git a/app/src/main/java/moe/apex/breadboard/preferences/GeneralSettingsScreen.kt b/app/src/main/java/moe/apex/breadboard/preferences/GeneralSettingsScreen.kt new file mode 100644 index 00000000..09a88c87 --- /dev/null +++ b/app/src/main/java/moe/apex/breadboard/preferences/GeneralSettingsScreen.kt @@ -0,0 +1,171 @@ +package moe.apex.breadboard.preferences + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +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 +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.navigation.NavHostController +import kotlinx.coroutines.launch +import moe.apex.breadboard.image.ImageBoardRequirement +import moe.apex.breadboard.prefs +import moe.apex.breadboard.util.AgeVerification +import moe.apex.breadboard.util.LargeTitleBar +import moe.apex.breadboard.util.LazyExpressiveGroup +import moe.apex.breadboard.util.MainScreenScaffold +import moe.apex.breadboard.util.MEDIUM_SPACER +import moe.apex.breadboard.util.TitleSummary +import moe.apex.breadboard.viewmodel.getGlobalViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun GeneralSettingsScreen(navController: NavHostController) { + val topAppBarState = rememberTopAppBarState() + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(topAppBarState) + val viewModel = getGlobalViewModel() + val context = LocalContext.current + val scope = rememberCoroutineScope() + + var showAuthDialog by remember { mutableStateOf(false) } + var showAgeVerificationDialog by remember { mutableStateOf(false) } + + val preferencesRepository = LocalContext.current.prefs + val currentSettings = LocalPreferences.current + + if (showAuthDialog) { + AuthDialog( + selectedBoard = currentSettings.imageSource.imageBoard, + default = currentSettings.authFor(currentSettings.imageSource, context), + onDismissRequest = { showAuthDialog = false } + ) { username, apiKey -> + scope.launch { + preferencesRepository.setAuth( + currentSettings.imageSource, + username.takeUnless { it.isBlank() }, + apiKey.takeUnless { it.isBlank() } + ) + } + showAuthDialog = false + viewModel.resetProviders() + } + } + + if (showAgeVerificationDialog) { + AgeVerification.AgeVerifyDialog( + onDismissRequest = { showAgeVerificationDialog = false }, + onAgeVerified = { showAgeVerificationDialog = false } + ) + } + + MainScreenScaffold( + topAppBar = { + LargeTitleBar( + title = "General", + scrollBehavior = scrollBehavior, + navController = navController + ) + } + ) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(it) + .nestedScroll(scrollBehavior.nestedScrollConnection), + contentPadding = PaddingValues(MEDIUM_SPACER.dp) + ) { + LazyExpressiveGroup(desiredTopPadding = null) { + item { + EnumPref( + title = "Image source", + summary = currentSettings.imageSource.label, + enumItems = ImageSource.entries, + selectedItem = currentSettings.imageSource, + onSelection = { + if (it == ImageSource.R34 && !AgeVerification.hasVerifiedAge(currentSettings)) { + showAgeVerificationDialog = true + return@EnumPref + } + scope.launch { + preferencesRepository.updatePref( + PreferenceKeys.IMAGE_SOURCE, + it + ) + } + viewModel.clearTagSuggestions() + } + ) + } + item { + val authType = currentSettings.imageSource.imageBoard.apiKeyRequirement + TitleSummary( + modifier = Modifier + .fillMaxWidth() + .animateContentSize(), + title = "Set API key", + summary = if (authType != ImageBoardRequirement.NOT_NEEDED) { + "${currentSettings.imageSource.label} requires an API key${if (authType == ImageBoardRequirement.RECOMMENDED) " for the best experience." else "."} " + + "Tap to set." + } else { + "${currentSettings.imageSource.label} does not require an API key." + }, + enabled = authType != ImageBoardRequirement.NOT_NEEDED + ) { + showAuthDialog = true + } + } + item { + SwitchPref( + checked = currentSettings.saveSearchHistory, + title = "Save search history", + summary = "Save your 10 most recent searches. When this is disabled, " + + "your search history will be cleared and Breadboard will not " + + "save future searches." + ) { + scope.launch { + if (!it) preferencesRepository.clearSearchHistory() + preferencesRepository.updatePref( + key = PreferenceKeys.SAVE_SEARCH_HISTORY, + to = it + ) + } + } + } + item { + SwitchPref( + checked = currentSettings.useFixedLinks, + title = "Share embeddable links", + summary = "When sharing an image, use a link that embeds better on external platforms when possible.", + infoText = "When embeddable links are enabled, sharing an image may " + + "use an alternative link depending on the source.\n\n" + + "Bilibili links are transformed into vxbilibili.com\n" + + "Bluesky links are transformed into fxbsky.app\n" + + "Pixiv links are transformed into phixiv.net\n" + + "Twitter links are transformed into fxtwitter.com\n" + + "Weibo links are transformed into fxweibo.com." + ) { + scope.launch { + preferencesRepository.updatePref( + PreferenceKeys.USE_FIXED_LINKS, + it + ) + } + } + } + } + } + } +} diff --git a/app/src/main/java/moe/apex/breadboard/preferences/LayoutSettingsScreen.kt b/app/src/main/java/moe/apex/breadboard/preferences/LayoutSettingsScreen.kt new file mode 100644 index 00000000..90aa3c72 --- /dev/null +++ b/app/src/main/java/moe/apex/breadboard/preferences/LayoutSettingsScreen.kt @@ -0,0 +1,171 @@ +package moe.apex.breadboard.preferences + +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 +import androidx.compose.runtime.rememberCoroutineScope +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.navigation.NavHostController +import kotlinx.coroutines.launch +import moe.apex.breadboard.prefs +import moe.apex.breadboard.util.LargeTitleBar +import moe.apex.breadboard.util.LazyExpressiveGroup +import moe.apex.breadboard.util.MainScreenScaffold +import moe.apex.breadboard.util.MEDIUM_SPACER + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LayoutSettingsScreen(navController: NavHostController) { + val topAppBarState = rememberTopAppBarState() + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(topAppBarState) + val scope = rememberCoroutineScope() + val preferencesRepository = LocalContext.current.prefs + val currentSettings = LocalPreferences.current + + MainScreenScaffold( + topAppBar = { + LargeTitleBar( + title = "Behaviour and layout", + scrollBehavior = scrollBehavior, + navController = navController + ) + } + ) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(it) + .nestedScroll(scrollBehavior.nestedScrollConnection), + contentPadding = PaddingValues(MEDIUM_SPACER.dp), + ) { + LazyExpressiveGroup( + title = "Behaviour", + desiredTopPadding = null + ) { + item { + EnumPref( + title = "Start page", + summary = currentSettings.defaultStartDestination.label, + enumItems = StartDestination.entries, + selectedItem = currentSettings.defaultStartDestination, + ) { + scope.launch { + preferencesRepository.updatePref( + PreferenceKeys.DEFAULT_START_DESTINATION, + it + ) + } + } + } + /* It's an enum so EnumPref would be the "correct" implementation, + but I think this is a better UX as there are currently only two options. */ + item { + SwitchPref( + checked = currentSettings.defaultBrowseTab == BrowseTab.FOLLOWING, + title = "Prefer Following tab", + summary = "By default, open the Following tab instead of the For You " + + "tab on the Browse page.", + ) { following -> + scope.launch { + preferencesRepository.updatePref( + PreferenceKeys.DEFAULT_BROWSE_TAB, + if (following) BrowseTab.FOLLOWING else BrowseTab.FOR_YOU + ) + } + } + } + item { + EnumPref( + title = "Hide app content", + summary = currentSettings.flagSecureMode.label, + enumItems = FlagSecureMode.entries, + selectedItem = currentSettings.flagSecureMode + ) { + scope.launch { + preferencesRepository.updatePref( + PreferenceKeys.FLAG_SECURE_MODE, + it + ) + } + } + } + item { + EnumPref( + title = "Data saver", + summary = currentSettings.dataSaver.label, + enumItems = DataSaver.entries, + infoText = "When data saver is enabled, images will load in a " + + "lower resolution by default.\n\n" + + "Downloads will always be in the maximum resolution " + + "regardless of this setting.", + selectedItem = currentSettings.dataSaver, + onSelection = { + scope.launch { + preferencesRepository.updatePref( + PreferenceKeys.DATA_SAVER, + it + ) + } + } + ) + } + } + + LazyExpressiveGroup("Layout") { + item { + ReorderablePref( + title = "Reorder image actions", + dialogTitle = "Image actions", + summary = "Customise the order of actions in the image viewer. The top " + + "action will be displayed separately in its own dedicated button.", + items = currentSettings.imageViewerActions + ) { + scope.launch { + preferencesRepository.updateEnumList( + PreferenceKeys.IMAGE_VIEWER_ACTION_ORDER, + it + ) + } + } + } + item { + SwitchPref( + checked = currentSettings.useStaggeredGrid, + title = "Staggered grid", + summary = "Use a staggered grid for images rather than a uniform grid." + ) { + scope.launch { + preferencesRepository.updatePref( + PreferenceKeys.USE_STAGGERED_GRID, + it + ) + } + } + } + item { + SwitchPref( + checked = currentSettings.unifiedInfoSheet, + title = "Classic info sheet", + summary = "Display all art information in a single scrollable " + + "list rather than organising it into tabs." + ) { + scope.launch { + preferencesRepository.updatePref( + PreferenceKeys.UNIFIED_INFO_SHEET, + it + ) + } + } + } + } + } + } +} 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 95cb776c..ef477b07 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/Preferences.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/Preferences.kt @@ -1,90 +1,57 @@ package moe.apex.breadboard.preferences -import android.net.Uri -import android.provider.OpenableColumns -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.animateContentSize -import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column +import androidx.core.graphics.ColorUtils import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Dashboard import androidx.compose.material.icons.rounded.Info import androidx.compose.material.icons.rounded.MoreVert import androidx.compose.material.icons.rounded.Science -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button +import androidx.compose.material.icons.rounded.Settings +import androidx.compose.material.icons.rounded.Storage +import androidx.compose.material.icons.rounded.Visibility import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import androidx.compose.runtime.toMutableStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.LinkAnnotation -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.TextLinkStyles -import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.style.TextDecoration -import androidx.compose.ui.text.withLink import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import moe.apex.breadboard.image.ImageBoard -import moe.apex.breadboard.image.ImageBoardAuth -import moe.apex.breadboard.image.ImageBoardRequirement import moe.apex.breadboard.navigation.AboutSettings -import moe.apex.breadboard.navigation.BlockedTagsSettings +import moe.apex.breadboard.navigation.ContentSettings +import moe.apex.breadboard.navigation.DataSettings import moe.apex.breadboard.navigation.ExperimentalSettings -import moe.apex.breadboard.navigation.RecommendationsSettings -import moe.apex.breadboard.prefs -import moe.apex.breadboard.util.AgeVerification +import moe.apex.breadboard.navigation.GeneralSettings +import moe.apex.breadboard.navigation.LayoutSettings import moe.apex.breadboard.util.ChevronRight -import moe.apex.breadboard.util.ExportDirectoryHandler -import moe.apex.breadboard.util.ExpressiveGroup +import moe.apex.breadboard.util.LazyExpressiveGroup import moe.apex.breadboard.util.MainScreenScaffold -import moe.apex.breadboard.util.StorageLocationSelection -import moe.apex.breadboard.util.ImportException -import moe.apex.breadboard.util.ImportHandler -import moe.apex.breadboard.util.LARGE_SPACER -import moe.apex.breadboard.util.PromptType import moe.apex.breadboard.util.MEDIUM_SPACER -import moe.apex.breadboard.util.SMALL_SPACER -import moe.apex.breadboard.util.TINY_SPACER import moe.apex.breadboard.util.TitleSummary -import moe.apex.breadboard.util.exportData -import moe.apex.breadboard.util.importData -import moe.apex.breadboard.util.launchInWebBrowser -import moe.apex.breadboard.util.preImportChecks -import moe.apex.breadboard.util.saveUriToPref -import moe.apex.breadboard.util.showToast -import moe.apex.breadboard.viewmodel.getGlobalViewModel -import org.json.JSONException -import org.json.JSONObject -import java.io.FileInputStream @OptIn(ExperimentalMaterial3Api::class) @@ -92,141 +59,6 @@ import java.io.FileInputStream fun PreferencesScreen(navController: NavHostController) { val topAppBarState = rememberTopAppBarState() val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(topAppBarState) - val viewModel = getGlobalViewModel() - val context = LocalContext.current - val scope = rememberCoroutineScope() - - var storageLocationPromptLaunched by remember { mutableStateOf(false) } - var showExportDialog by remember { mutableStateOf(false) } - var exportedData: JSONObject? by remember { mutableStateOf(null) } - var importedData: JSONObject? by remember { mutableStateOf(null) } - var importingStarted by rememberSaveable { mutableStateOf(false) } - var showAuthDialog by remember { mutableStateOf(false) } - var showAgeVerificationDialog by remember { mutableStateOf(false) } - - val preferencesRepository = LocalContext.current.prefs - val currentSettings = LocalPreferences.current - - if (showAuthDialog) { - AuthDialog( - selectedBoard = currentSettings.imageSource.imageBoard, - default = currentSettings.authFor(currentSettings.imageSource, context), - onDismissRequest = { showAuthDialog = false } - ) { username, apiKey -> - scope.launch { - preferencesRepository.setAuth( - currentSettings.imageSource, - username.takeUnless { it.isBlank() }, - apiKey.takeUnless { it.isBlank() } - ) - } - showAuthDialog = false - viewModel.resetProviders() - } - } - - if (showAgeVerificationDialog) { - AgeVerification.AgeVerifyDialog( - onDismissRequest = { showAgeVerificationDialog = false }, - onAgeVerified = { showAgeVerificationDialog = false } - ) - } - - if (storageLocationPromptLaunched) { - StorageLocationSelection( - promptType = PromptType.DIRECTORY_PERMISSION, - onFailure = { storageLocationPromptLaunched = false } - ) { uri -> - saveUriToPref(context, scope, uri) - storageLocationPromptLaunched = false - } - } - - if (showExportDialog) { - val categories = remember { PrefCategory.entries.toMutableStateList() } - ExportDialog( - enabledCategories = categories, - onDismissRequest = { showExportDialog = false } - ) { - scope.launch { - exportedData = exportData(context, categories) - showExportDialog = false - } - } - } - - if (exportedData != null) { - ExportDirectoryHandler(exportedData!!) { exportedData = null } - } - - if (importingStarted) { - ImportHandler({ importingStarted = false }) { uri -> - var displayName = uri.lastPathSegment.toString() - if (!displayName.endsWith(".bread")) { - /* Android's content provider also sometimes returns URIs that hide the real file - path and name so we need to query the content resolver to get the correct one. */ - context.contentResolver.query(uri, null, null, null).use { cursor -> - if (cursor?.moveToFirst() == true) { - val displayNameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) - if (displayNameIndex != -1) { - displayName = cursor.getString(displayNameIndex) - } - } - } - } - if (!displayName.endsWith(".bread")) { - showToast(context, "Invalid file type.") - importingStarted = false - return@ImportHandler - } - - scope.launch(Dispatchers.IO) { - context.contentResolver.openFileDescriptor(uri, "r").use { fd -> - FileInputStream(fd!!.fileDescriptor).use { fis -> - try { - val json = JSONObject(fis.readBytes().decodeToString()) - val checkResult = preImportChecks(currentSettings, json) - if (!checkResult.isSuccess) { - throw checkResult.exceptionOrNull()!! - } - importedData = json - } catch (e: Exception) { - withContext(Dispatchers.Main) { - showToast( - context = context, - text = when (e) { - is JSONException -> "Invalid backup provided." - is ImportException -> e.message!! - else -> "Unknown error." - } - ) - } - } - } - } - importingStarted = false - } - } - } - - if (importedData != null) { - ImportDialog( - allowedCategories = PrefCategory.entries.filter { pc -> pc == PrefCategory.BUILD || pc.name in importedData!! }, - onDismissRequest = { importedData = null} - ) { categories, merge -> - scope.launch { - val result = importData(context, importedData!!, categories, merge) - withContext(Dispatchers.Main) { - if (result.isFailure) { - showToast(context, result.exceptionOrNull()!!.message!!) - } else { - showToast(context, "Imported successfully.") - } - } - importedData = null - } - } - } MainScreenScaffold( title = "Settings", @@ -243,22 +75,9 @@ fun PreferencesScreen(navController: NavHostController) { DropdownMenu( expanded = isDropdownVisible, containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - shape = MaterialTheme.shapes.small, + shape = MaterialTheme.shapes.medium, onDismissRequest = { isDropdownVisible = false } ) { - DropdownMenuItem( - text = { Text("About") }, - leadingIcon = { - Icon( - imageVector = Icons.Rounded.Info, - contentDescription = "About" - ) - }, - onClick = { - isDropdownVisible = false - navController.navigate(AboutSettings) - } - ) DropdownMenuItem( text = { Text("Experimental features") }, leadingIcon = { @@ -281,328 +100,72 @@ fun PreferencesScreen(navController: NavHostController) { .fillMaxSize() .padding(it) .nestedScroll(scrollBehavior.nestedScrollConnection), - contentPadding = PaddingValues(vertical = MEDIUM_SPACER.dp), - verticalArrangement = Arrangement.spacedBy(LARGE_SPACER.dp) + contentPadding = PaddingValues(MEDIUM_SPACER.dp) ) { - item { - ExpressiveGroup("General") { - item { - EnumPref( - title = "Image source", - summary = currentSettings.imageSource.label, - enumItems = ImageSource.entries, - selectedItem = currentSettings.imageSource, - onSelection = { - if (it == ImageSource.R34 && !AgeVerification.hasVerifiedAge(currentSettings)) { - showAgeVerificationDialog = true - return@EnumPref - } - scope.launch { - preferencesRepository.updatePref( - PreferenceKeys.IMAGE_SOURCE, - it - ) - } - viewModel.clearTagSuggestions() - } - ) - } - item { - val authType = currentSettings.imageSource.imageBoard.apiKeyRequirement - TitleSummary( - modifier = Modifier - .fillMaxWidth() - .animateContentSize(), - title = "Set API key", - summary = if (authType != ImageBoardRequirement.NOT_NEEDED) { - "${currentSettings.imageSource.label} requires an API key${if (authType == ImageBoardRequirement.RECOMMENDED) " for the best experience." else "."} " + - "Tap to set." - } else { - "${currentSettings.imageSource.label} does not require an API key." - }, - enabled = authType != ImageBoardRequirement.NOT_NEEDED - ) { - showAuthDialog = true - } - } - item { - SwitchPref( - checked = currentSettings.saveSearchHistory, - title = "Save search history", - summary = "Save your 10 most recent searches. When this is disabled, your " + - "search history will be cleared and Breadboard will not save future " + - "searches." - ) { - scope.launch { - if (!it) preferencesRepository.clearSearchHistory() - preferencesRepository.updatePref( - key = PreferenceKeys.SAVE_SEARCH_HISTORY, - to = it - ) - } - } - } - item { - SwitchPref( - checked = currentSettings.useFixedLinks, - title = "Share embeddable links", - summary = "When sharing an image, use a link that embeds better on external platforms when possible.", - infoText = "When embeddable links are enabled, sharing an image may " + - "use an alternative link depending on the source.\n\n" + - "Bilibili links are transformed into vxbilibili.com\n" + - "Bluesky links are transformed into fxbsky.app\n" + - "Pixiv links are transformed into phixiv.net\n" + - "Twitter links are transformed into fxtwitter.com\n" + - "Weibo links are transformed into fxweibo.com." - ) { - scope.launch { - preferencesRepository.updatePref( - PreferenceKeys.USE_FIXED_LINKS, - it - ) - } - } + LazyExpressiveGroup(desiredTopPadding = null) { + item { + TitleSummary( + modifier = Modifier.fillMaxWidth(), + title = "General", + summary = "Image source, API keys, search history, and links.", + leadingIcon = { + ContainedIcon(imageVector = Icons.Rounded.Settings, label = "General") + }, + trailingIcon = { ChevronRight() } + ) { + navController.navigate(GeneralSettings) } } - } - - item { - ExpressiveGroup(title = "Content") { - item { - TitleSummary( - modifier = Modifier.fillMaxWidth(), - title = "Manage blocked tags", - summary = "Add or remove tags to block from search results and recommendations.", - trailingIcon = { ChevronRight() } - ) { - navController.navigate(BlockedTagsSettings) - } - } - item { - TitleSummary( - modifier = Modifier.fillMaxWidth(), - title = "Manage recommendations", - summary = "Fine-tune your recommendations by customising which tags can be used to recommend new content.", - trailingIcon = { ChevronRight() } - ) { - navController.navigate(RecommendationsSettings) - } - } - item { - SwitchPref( - checked = currentSettings.excludeAi, - title = "Hide AI-generated images", - summary = "Attempt to hide AI-generated images by automatically adding " + - "AI-related tags to your block list." - ) { - scope.launch { - preferencesRepository.updatePref( - PreferenceKeys.EXCLUDE_AI, - it - ) - } - viewModel.resetProviders() - } - } - item { - SwitchPref( - checked = currentSettings.filterRatingsLocally, - title = "Filter ratings locally", - summary = "Rather than appending the selected ratings to the search query, " + - "filter the results by rating after searching.", - infoText = "Danbooru limits searches to 2 tags " + - "(which includes ratings) without an API key.\n\n" + - "Enabling this option will allow you to filter by rating " + - "on all sources without an API key, and also let you filter " + - "ratings mid-search, but may cause less results to be shown " + - "at once, resulting in slightly higher data usage.\n\n" + - "Yande.re always requires this option.\n\n" + - "If you're unsure, keep this option enabled." - ) { - scope.launch { - preferencesRepository.updatePref( - PreferenceKeys.FILTER_RATINGS_LOCALLY, - it - ) - } - } - } - item { - EnumPref( - title = "Automatically play videos", - summary = currentSettings.autoplayVideos.label, - infoText = "Control whether videos will automatically play when " + - "opening them.\n\n" + - "When this is enabled, videos will start muted until you " + - "manually unmute by tapping the icon or your device's " + - "volume up button.", - enumItems = AutoplayVideosMode.entries, - selectedItem = currentSettings.autoplayVideos, - onSelection = { - scope.launch { - preferencesRepository.updatePref( - PreferenceKeys.AUTOPLAY_VIDEOS, - it - ) - } - } - ) + item { + TitleSummary( + modifier = Modifier.fillMaxWidth(), + title = "Content", + summary = "Blocked tags, recommendations, and video settings.", + leadingIcon = { + ContainedIcon(imageVector = Icons.Rounded.Visibility, label = "Content") + }, + trailingIcon = { ChevronRight() } + ) { + navController.navigate(ContentSettings) } } - } - - item { - ExpressiveGroup(title = "Interaction and layout") { - item { - EnumPref( - title = "Start page", - summary = currentSettings.defaultStartDestination.label, - enumItems = StartDestination.entries, - selectedItem = currentSettings.defaultStartDestination, - ) { - scope.launch { - preferencesRepository.updatePref( - PreferenceKeys.DEFAULT_START_DESTINATION, - it - ) - } - } - } - /* It's an enum so EnumPref would be the "correct" implementation, - but I think this is a better UX as there are currently only two options. */ - item { - SwitchPref( - checked = currentSettings.defaultBrowseTab == BrowseTab.FOLLOWING, - title = "Prefer Following tab", - summary = "By default, open the Following tab instead of the For You " + - "tab on the Browse page.", - ) { following -> - scope.launch { - preferencesRepository.updatePref( - PreferenceKeys.DEFAULT_BROWSE_TAB, - if (following) BrowseTab.FOLLOWING else BrowseTab.FOR_YOU - ) - } - } - } - item { - EnumPref( - title = "Hide app content", - summary = currentSettings.flagSecureMode.label, - enumItems = FlagSecureMode.entries, - selectedItem = currentSettings.flagSecureMode - ) { - scope.launch { - preferencesRepository.updatePref( - PreferenceKeys.FLAG_SECURE_MODE, - it - ) - } - } - } - item { - SwitchPref( - checked = currentSettings.useStaggeredGrid, - title = "Staggered grid", - summary = "Use a staggered grid for images rather than a uniform grid." - ) { - scope.launch { - preferencesRepository.updatePref( - PreferenceKeys.USE_STAGGERED_GRID, - it - ) - } - } - } - item { - SwitchPref( - checked = currentSettings.unifiedInfoSheet, - title = "Classic info sheet", - summary = "Display all art information in a single scrollable " + - "list rather than organising it into tabs." - ) { - scope.launch { - preferencesRepository.updatePref( - PreferenceKeys.UNIFIED_INFO_SHEET, - it - ) - } - } - } - item { - ReorderablePref( - title = "Reorder image actions", - dialogTitle = "Image actions", - summary = "Customise the order of actions in the image viewer. The top " + - "action will be displayed separately in its own dedicated button.", - items = currentSettings.imageViewerActions - ) { - scope.launch { - preferencesRepository.updateEnumList( - PreferenceKeys.IMAGE_VIEWER_ACTION_ORDER, - it - ) - } - } + item { + TitleSummary( + modifier = Modifier.fillMaxWidth(), + title = "Behaviour and layout", + summary = "Start page, app layout, and image viewer actions.", + leadingIcon = { + ContainedIcon(imageVector = Icons.Rounded.Dashboard, label = "Behaviour and layout") + }, + trailingIcon = { ChevronRight() } + ) { + navController.navigate(LayoutSettings) } } - } - - item { - ExpressiveGroup("Data and storage") { - item { - EnumPref( - title = "Data saver", - summary = currentSettings.dataSaver.label, - enumItems = DataSaver.entries, - infoText = "When data saver is enabled, images will load in a " + - "lower resolution by default.\n\n" + - "Downloads will always be in the maximum resolution " + - "regardless of this setting.", - selectedItem = currentSettings.dataSaver, - onSelection = { - scope.launch { - preferencesRepository.updatePref( - PreferenceKeys.DATA_SAVER, - it - ) - } - } - ) - } - item { - TitleSummary( - modifier = Modifier.fillMaxWidth(), - title = "Save downloads to", - summary = if (currentSettings.storageLocation == Uri.EMPTY) "Tap to set" - else currentSettings.storageLocation.toString() - ) { - storageLocationPromptLaunched = true - } + item { + TitleSummary( + modifier = Modifier.fillMaxWidth(), + title = "Data and storage", + summary = "Download location and your Breadboard data.", + leadingIcon = { + ContainedIcon(imageVector = Icons.Rounded.Storage, label = "Data and storage") + }, + trailingIcon = { ChevronRight() } + ) { + navController.navigate(DataSettings) } } - } - - item { - ExpressiveGroup(title = "Import/export") { - item { - TitleSummary( - modifier = Modifier.fillMaxWidth(), - title = "Export data", - summary = "Export a backup file containing your current settings, favourite images, " + - "and search history." - ) { - showExportDialog = true - } - } - item { - TitleSummary( - modifier = Modifier.fillMaxWidth(), - title = "Import data", - summary = "Import a Breadboard backup file." - ) { - importingStarted = true - } + item { + TitleSummary( + modifier = Modifier.fillMaxWidth(), + title = "About", + summary = "Breadboard info and licenses.", + leadingIcon = { + ContainedIcon(imageVector = Icons.Rounded.Info, label = "About") + }, + trailingIcon = { ChevronRight() } + ) { + navController.navigate(AboutSettings) } } } @@ -611,160 +174,48 @@ fun PreferencesScreen(navController: NavHostController) { } +@OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable -private fun AuthDialog( - selectedBoard: ImageBoard, - default: ImageBoardAuth?, - onDismissRequest: () -> Unit, - onSave: (String, String) -> Unit +private fun ContainedIcon( + label: String, + imageVector: ImageVector ) { - var inQueryParamMode by remember { mutableStateOf(false) } - val queryRegex = remember { Regex("&api_key=([^&]+)&user_id=(\\d+)") } - var queryString by remember { mutableStateOf("") } - var isQueryStringValid by remember { mutableStateOf(false) } - - var userId by remember { mutableStateOf(default?.user ?: "") } - var apiKey by remember { mutableStateOf(default?.apiKey ?: "") } - - val context = LocalContext.current - - AlertDialog( - onDismissRequest = onDismissRequest, - title = { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text("Set API key") - Box { - var showMenu by remember { mutableStateOf(false) } - IconButton(onClick = { showMenu = true }) { - Icon( - imageVector = Icons.Rounded.MoreVert, - contentDescription = "Options" - ) - } - DropdownMenu( - expanded = showMenu, - onDismissRequest = { showMenu = false }, - containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - shape = MaterialTheme.shapes.small - ) { - DropdownMenuItem( - text = { Text(if (inQueryParamMode) "Enter credentials normally" else "Enter query string") }, - onClick = { - inQueryParamMode = !inQueryParamMode - showMenu = false - } - ) - } - } - } - }, - text = { - Column { - if (inQueryParamMode) { - PreferenceTextBox( - value = queryString, - label = "Query string", - obscured = false, - keyboardType = KeyboardType.Uri, - isError = !isQueryStringValid && queryString.isNotEmpty() - ) { newValue -> - queryString = newValue.trim() - isQueryStringValid = queryRegex.containsMatchIn(queryString) - } - } else { - PreferenceTextBox( - value = userId, - label = "User ID/name", - obscured = false - ) { - userId = it.trim() - } - } - - AnimatedVisibility(!inQueryParamMode) { - PreferenceTextBox( - modifier = Modifier.padding(top = SMALL_SPACER.dp), - value = apiKey, - label = "API key", - keyboardType = KeyboardType.Password, - obscured = true - ) { - apiKey = it.trim() - } - } - - selectedBoard.apiKeyCreationUrl?.let { url -> - val apiKeyCreationText = buildAnnotatedString { - val link = LinkAnnotation.Url( - url, - TextLinkStyles( - SpanStyle(color = MaterialTheme.colorScheme.secondary, textDecoration = TextDecoration.Underline) - ) - ) { - launchInWebBrowser(context, url) - } - - withLink(link) { - append("Find your credentials...") - } - } + val isDark = isSystemInDarkTheme() + // Assign the icon a random colour hue based on its label. + val seed = label.hashCode() + val hue = (seed and Integer.MAX_VALUE).rem(360).toFloat() + + /* We're going for light backgrounds with a darker inner icon. + The specific saturation and lightness depend on whether the user is in dark or light mode. */ + val (bgSat, bgLight) = if (isDark) { + 0.42f to 0.70f + } else { + 0.65f to 0.87f + } - Text( - text = apiKeyCreationText, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding( - start = TINY_SPACER.dp, - top = SMALL_SPACER.dp - ) - ) - } - } - }, - confirmButton = { - Button( - enabled = if (inQueryParamMode) { - isQueryStringValid || queryString.isBlank() - } else { - (userId.isNotBlank() && apiKey.isNotBlank()) || (userId.isBlank() && apiKey.isBlank()) - }, - onClick = { - if (inQueryParamMode) { - if (queryString.isBlank()) { - onSave("", "") - return@Button - } + val (iconSat, iconLight) = if (isDark) { + 0.60f to 0.20f + } else { + 0.65f to 0.27f + } - val match = queryRegex.find(queryString) - if (match != null) { - onSave(match.groupValues[2], match.groupValues[1]) - } - - } else { - onSave(userId, apiKey) - } - } - ) { - Text("Save") - } - }, - dismissButton = { - TextButton(onClick = onDismissRequest) { - Text("Cancel") - } - } - ) -} + val bgInt = ColorUtils.HSLToColor(floatArrayOf(hue, bgSat, bgLight)) + val iconInt = ColorUtils.HSLToColor(floatArrayOf(hue, iconSat, iconLight)) + val bgColour = Color(bgInt) + val iconTint = Color(iconInt) -private operator fun JSONObject.contains(key: String): Boolean { - return try { - get(key) - true - } catch (_: JSONException) { - false + Box( + modifier = Modifier + .clip(CircleShape) + .background(bgColour) + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = imageVector, + contentDescription = null, + tint = iconTint + ) } } 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 60a47292..eba00ad7 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/PreferencesUI.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/PreferencesUI.kt @@ -1,8 +1,10 @@ package moe.apex.breadboard.preferences +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -25,9 +27,12 @@ import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.Clear import androidx.compose.material.icons.rounded.DragHandle import androidx.compose.material.icons.rounded.Info +import androidx.compose.material.icons.rounded.MoreVert import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Checkbox +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 @@ -58,19 +63,28 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.OffsetMapping import androidx.compose.ui.text.input.TransformedText import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withLink import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import moe.apex.breadboard.image.ImageBoard +import moe.apex.breadboard.image.ImageBoardAuth import moe.apex.breadboard.ui.theme.BreadboardTheme import moe.apex.breadboard.ui.theme.prefTitle import moe.apex.breadboard.ui.theme.searchField @@ -82,9 +96,11 @@ import moe.apex.breadboard.util.SMALL_LARGE_SPACER import moe.apex.breadboard.util.SMALL_SPACER import moe.apex.breadboard.util.SmallVerticalSpacer import moe.apex.breadboard.util.Summary +import moe.apex.breadboard.util.TINY_SPACER import moe.apex.breadboard.util.TitleSummary import moe.apex.breadboard.util.TitledModalBottomSheet import moe.apex.breadboard.util.VerticalSpacer +import moe.apex.breadboard.util.launchInWebBrowser import moe.apex.breadboard.util.largerShape import moe.apex.breadboard.util.navBarHeight import sh.calvin.reorderable.ReorderableItem @@ -605,6 +621,155 @@ fun > ReorderablePref( } +@Composable +fun AuthDialog( + selectedBoard: ImageBoard, + default: ImageBoardAuth?, + onDismissRequest: () -> Unit, + onSave: (String, String) -> Unit +) { + var inQueryParamMode by remember { mutableStateOf(false) } + val queryRegex = remember { Regex("&api_key=([^&]+)&user_id=(\\d+)") } + var queryString by remember { mutableStateOf("") } + var isQueryStringValid by remember { mutableStateOf(false) } + + var userId by remember { mutableStateOf(default?.user ?: "") } + var apiKey by remember { mutableStateOf(default?.apiKey ?: "") } + + val context = LocalContext.current + + AlertDialog( + onDismissRequest = onDismissRequest, + title = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text("Set API key") + Box { + var showMenu by remember { mutableStateOf(false) } + IconButton(onClick = { showMenu = true }) { + Icon( + imageVector = Icons.Rounded.MoreVert, + contentDescription = "Options" + ) + } + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false }, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = MaterialTheme.shapes.small + ) { + DropdownMenuItem( + text = { Text(if (inQueryParamMode) "Enter credentials normally" else "Enter query string") }, + onClick = { + inQueryParamMode = !inQueryParamMode + showMenu = false + } + ) + } + } + } + }, + text = { + Column { + if (inQueryParamMode) { + PreferenceTextBox( + value = queryString, + label = "Query string", + obscured = false, + keyboardType = KeyboardType.Uri, + isError = !isQueryStringValid && queryString.isNotEmpty() + ) { newValue -> + queryString = newValue.trim() + isQueryStringValid = queryRegex.containsMatchIn(queryString) + } + } else { + PreferenceTextBox( + value = userId, + label = "User ID/name", + obscured = false + ) { + userId = it.trim() + } + } + + AnimatedVisibility(!inQueryParamMode) { + PreferenceTextBox( + modifier = Modifier.padding(top = SMALL_SPACER.dp), + value = apiKey, + label = "API key", + keyboardType = KeyboardType.Password, + obscured = true + ) { + apiKey = it.trim() + } + } + + selectedBoard.apiKeyCreationUrl?.let { url -> + val apiKeyCreationText = buildAnnotatedString { + val link = LinkAnnotation.Url( + url, + TextLinkStyles( + SpanStyle(color = MaterialTheme.colorScheme.secondary, textDecoration = TextDecoration.Underline) + ) + ) { + launchInWebBrowser(context, url) + } + + withLink(link) { + append("Find your credentials...") + } + } + + Text( + text = apiKeyCreationText, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding( + start = TINY_SPACER.dp, + top = SMALL_SPACER.dp + ) + ) + } + } + }, + confirmButton = { + Button( + enabled = if (inQueryParamMode) { + isQueryStringValid || queryString.isBlank() + } else { + (userId.isNotBlank() && apiKey.isNotBlank()) || (userId.isBlank() && apiKey.isBlank()) + }, + onClick = { + if (inQueryParamMode) { + if (queryString.isBlank()) { + onSave("", "") + return@Button + } + + val match = queryRegex.find(queryString) + if (match != null) { + onSave(match.groupValues[2], match.groupValues[1]) + } + + } else { + onSave(userId, apiKey) + } + } + ) { + Text("Save") + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text("Cancel") + } + } + ) +} + + data class ToggleablePrefCategory( var available: Boolean = true, var enabled: Boolean, diff --git a/app/src/main/java/moe/apex/breadboard/ui/theme/Type.kt b/app/src/main/java/moe/apex/breadboard/ui/theme/Type.kt index 0c31fdc7..84e298d9 100644 --- a/app/src/main/java/moe/apex/breadboard/ui/theme/Type.kt +++ b/app/src/main/java/moe/apex/breadboard/ui/theme/Type.kt @@ -35,7 +35,7 @@ val Typography = Typography( titleMedium = Typography().titleMedium.copy( fontFamily = FontFamily(Font(R.font.kumbh)), fontWeight = FontWeight.Bold, - fontSize = 15.sp, + fontSize = 14.sp, ), headlineMedium = Typography().headlineMedium.copy( fontFamily = FontFamily(Font(R.font.special_gothic_one_expanded)), @@ -57,8 +57,8 @@ val Typography.searchField: TextStyle val Typography.prefTitle: TextStyle get() = Typography().bodyLarge.copy( - fontSize = 17.sp, - lineHeight = 17.sp, + fontSize = 16.sp, + lineHeight = 16.sp, letterSpacing = 0.sp, fontFamily = FontFamily(Font(R.font.kumbh)), fontWeight = FontWeight.Bold diff --git a/app/src/main/java/moe/apex/breadboard/util/PrefUtils.kt b/app/src/main/java/moe/apex/breadboard/util/PrefUtils.kt index 6988a861..c63ac738 100644 --- a/app/src/main/java/moe/apex/breadboard/util/PrefUtils.kt +++ b/app/src/main/java/moe/apex/breadboard/util/PrefUtils.kt @@ -12,21 +12,27 @@ suspend fun saveWithTimestamp( context: Context, timestampKey: Preferences.Key, dataKey: Preferences.Key, - data: T + data: T, + timestamp: Long = System.currentTimeMillis() ) { context.dataStore.edit { prefs -> - prefs[timestampKey] = System.currentTimeMillis() + prefs[timestampKey] = timestamp prefs[dataKey] = data } } -suspend fun saveIgnoreListWithTimestamp(context: Context, data: Set) { +suspend fun saveIgnoreListWithTimestamp( + context: Context, + data: Set, + timestamp: Long = System.currentTimeMillis() +) { saveWithTimestamp( context = context, timestampKey = PreferenceKeys.INTERNAL_IGNORE_LIST_TIMESTAMP, dataKey = PreferenceKeys.INTERNAL_IGNORE_LIST, - data = data + data = data, + timestamp = timestamp ) } 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 6ea9bd98..5d915d11 100644 --- a/app/src/main/java/moe/apex/breadboard/util/Ui.kt +++ b/app/src/main/java/moe/apex/breadboard/util/Ui.kt @@ -150,6 +150,8 @@ const val LARGE_SPACER = 24 const val MEDIUM_LARGE_SPACER = 20 /** 16dp spacing */ const val SMALL_LARGE_SPACER = 16 +/** 18dp spacing */ +const val TITLE_SUMMARY_VERTICAL_SPACING = 18 /** 12dp spacing */ const val MEDIUM_SPACER = 12 /** 8dp spacing */ @@ -444,7 +446,7 @@ fun TitleSummary( trailingIcon: @Composable (() -> Unit)? = null, onClick: (() -> Unit)? = null ) { - val baseModifier = modifier.heightIn(min = 76.dp) + val baseModifier = modifier.heightIn(min = 72.dp) val finalModifier = onClick?.let { baseModifier.clickable(enabled) { it() } } ?: baseModifier Row( @@ -455,7 +457,8 @@ fun TitleSummary( Spacer(Modifier.width(SMALL_LARGE_SPACER.dp)) Box( modifier = Modifier - .size(48.dp) + .padding(vertical = SMALL_LARGE_SPACER.dp) + .size(42.dp) .alpha(if (enabled) 1f else DISABLED_OPACITY), contentAlignment = Alignment.Center ) { @@ -463,33 +466,27 @@ fun TitleSummary( } } Column( + // H/V padding are mismatched but this looks more comfortable to me. modifier = Modifier .weight(1f) - .padding(horizontal = SMALL_LARGE_SPACER.dp) - .alpha(if (enabled) 1f else DISABLED_OPACITY) + .padding(horizontal = SMALL_LARGE_SPACER.dp, vertical = TITLE_SUMMARY_VERTICAL_SPACING.dp, ) + .alpha(if (enabled) 1f else DISABLED_OPACITY), + verticalArrangement = Arrangement.spacedBy(2.dp) ) { Text( text = title, style = MaterialTheme.typography.prefTitle, - modifier = Modifier - .padding( - top = SMALL_LARGE_SPACER.dp, - bottom = (if (summary == null) SMALL_LARGE_SPACER.dp else 2.dp) - ) ) if (summary != null) { - Summary( - text = summary, - modifier = Modifier.padding(bottom = SMALL_LARGE_SPACER.dp) - ) + Summary(text = summary) } } trailingIcon?.let { Box( modifier = Modifier .padding(end = SMALL_SPACER.dp) - .size(48.dp) + .size(42.dp) .alpha(if (enabled) 1f else DISABLED_OPACITY), contentAlignment = Alignment.Center ) { @@ -1132,12 +1129,12 @@ fun BasicExpressiveGroup( * * For some reason, the inner container may stop responding to taps after a certain point if the * content inside is really long. It's something to do with clipping the shape but I don't know - * exactly what or why. Set [useBox] to `true` if this is likely to happen. + * exactly what or why. Set [clip] to `false` if this is likely to happen. * Note this will prevent tap ripples from conforming to the shape of the container. */ fun LazyListScope.LazyExpressiveGroup( - desiredTopPadding: Dp? = LARGE_SPACER.dp, title: String? = null, - useBox: Boolean = false, + desiredTopPadding: Dp? = LARGE_SPACER.dp, + clip: Boolean = true, content: ExpressiveGroupScope.() -> Unit ) { val scope = ExpressiveGroupScopeImpl() @@ -1174,12 +1171,12 @@ fun LazyListScope.LazyExpressiveGroup( item { val position = remember { ListItemPosition.fromIndex(scope.items, index) } - if (useBox) { - BoxBasedBasicExpressiveContainer(position = position) { + if (clip) { + BasicExpressiveContainer(position = position) { itemContent() } } else { - BasicExpressiveContainer(position = position) { + BoxBasedBasicExpressiveContainer(position = position) { itemContent() } } From ad95c25b37f92c4aaf37047c9203424e72e1adf0 Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Sat, 4 Jul 2026 02:40:51 +0100 Subject: [PATCH 10/59] Add ability to see who you're following --- .../breadboard/artist/ArtistProfileScreen.kt | 4 + .../breadboard/home/FollowedArtistsScreen.kt | 178 ++++++++++++++++++ .../moe/apex/breadboard/home/HomeScreen.kt | 90 ++++++--- .../breadboard/navigation/Destinations.kt | 3 + .../apex/breadboard/navigation/Navigation.kt | 19 +- .../breadboard/preferences/Preferences.kt | 33 +--- .../main/java/moe/apex/breadboard/util/Ui.kt | 46 +++++ 7 files changed, 313 insertions(+), 60 deletions(-) create mode 100644 app/src/main/java/moe/apex/breadboard/home/FollowedArtistsScreen.kt diff --git a/app/src/main/java/moe/apex/breadboard/artist/ArtistProfileScreen.kt b/app/src/main/java/moe/apex/breadboard/artist/ArtistProfileScreen.kt index 2fe7c80d..d192b032 100644 --- a/app/src/main/java/moe/apex/breadboard/artist/ArtistProfileScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/artist/ArtistProfileScreen.kt @@ -109,6 +109,7 @@ import moe.apex.breadboard.util.TINY_SPACER import moe.apex.breadboard.util.copyText import moe.apex.breadboard.util.showToast import moe.apex.breadboard.viewmodel.ArtistProfileViewModel +import moe.apex.breadboard.viewmodel.getGlobalViewModel import kotlin.random.Random @@ -238,6 +239,7 @@ private fun ArtistToolbar(artistTag: String, ) { val context = LocalContext.current val clipboard = LocalClipboard.current val prefs = LocalPreferences.current + val viewModel = getGlobalViewModel() val scope = rememberCoroutineScope() val preferencesRepository = context.prefs @@ -280,6 +282,8 @@ private fun ArtistToolbar(artistTag: String, ) { artistTag ) } + }.invokeOnCompletion { + viewModel.setFollowingProvider(null) } }, colors = ToggleButtonDefaults.toggleButtonColors( diff --git a/app/src/main/java/moe/apex/breadboard/home/FollowedArtistsScreen.kt b/app/src/main/java/moe/apex/breadboard/home/FollowedArtistsScreen.kt new file mode 100644 index 00000000..8ea677f6 --- /dev/null +++ b/app/src/main/java/moe/apex/breadboard/home/FollowedArtistsScreen.kt @@ -0,0 +1,178 @@ +package moe.apex.breadboard.home + +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.PersonOff +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.navigation.NavHostController +import kotlinx.coroutines.launch +import moe.apex.breadboard.navigation.ArtistProfile +import moe.apex.breadboard.preferences.LocalPreferences +import moe.apex.breadboard.preferences.PreferenceKeys +import moe.apex.breadboard.prefs +import moe.apex.breadboard.util.DISABLED_OPACITY +import moe.apex.breadboard.util.ExpressiveTagEntryContainer +import moe.apex.breadboard.util.LargeTitleBar +import moe.apex.breadboard.util.LargeVerticalSpacer +import moe.apex.breadboard.util.ListItemPosition +import moe.apex.breadboard.util.MEDIUM_SPACER +import moe.apex.breadboard.util.MainScreenScaffold +import moe.apex.breadboard.util.SMALL_LARGE_SPACER +import moe.apex.breadboard.util.Summary +import moe.apex.breadboard.util.TINY_SPACER +import moe.apex.breadboard.util.generateColours +import moe.apex.breadboard.viewmodel.getGlobalViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun FollowedArtistsScreen(navController: NavHostController) { + val viewModel = getGlobalViewModel() + val topAppBarState = rememberTopAppBarState() + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(topAppBarState) + val scope = rememberCoroutineScope() + val userPreferencesRepository = LocalContext.current.prefs + val prefs = LocalPreferences.current + val followedTags = prefs.followedTags.toList().sorted() + val darkTheme = isSystemInDarkTheme() + + MainScreenScaffold( + topAppBar = { + LargeTitleBar( + title = "Followed artists", + scrollBehavior = scrollBehavior, + navController = navController + ) + } + ) { + LazyColumn( + modifier = Modifier + .padding(it) + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection), + verticalArrangement = Arrangement.spacedBy(2.dp), + contentPadding = PaddingValues( + start = MEDIUM_SPACER.dp, + end = MEDIUM_SPACER.dp, + top = SMALL_LARGE_SPACER.dp, // Unbalanced but it looks better with the text. + bottom = MEDIUM_SPACER.dp + ), + ) { + item { + Summary( + modifier = Modifier.padding(horizontal = TINY_SPACER.dp), + text = "Posts from these artists will appear in your Following feed.\n" + + "Tap an artist's name to visit their profile, or unfollow them by " + + "tapping the icon.", + ) + LargeVerticalSpacer() + } + if (followedTags.isEmpty()) { + item { + Column( + modifier = Modifier + .fillMaxWidth() + .alpha(DISABLED_OPACITY), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + imageVector = Icons.Rounded.PersonOff, + contentDescription = null, + modifier = Modifier.size(120.dp) + ) + Text( + text = "You aren't following anyone yet.", + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center + ) + } + } + return@LazyColumn + } + + itemsIndexed(followedTags, key = { _, tag -> tag }) { index, tag -> + val monogramChar = remember(tag) { + tag.firstOrNull()?.uppercaseChar()?.toString() ?: "?" + } + val colourPair = remember(tag) { + generateColours(darkTheme, tag) + } + + ExpressiveTagEntryContainer( + modifier = Modifier.animateItem(), + label = tag, + position = ListItemPosition.fromIndex(followedTags, index), + leadingContent = { + Box( + modifier = Modifier + .size(40.dp) + .clip(CircleShape) + .background(colourPair.first), + contentAlignment = Alignment.Center + ) { + Text( + text = monogramChar, + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + color = colourPair.second + ) + } + }, + onClick = { + navController.navigate(ArtistProfile(tag)) + }, + trailingContent = { + IconButton( + onClick = { + scope.launch { + userPreferencesRepository.removeFromSet( + PreferenceKeys.FOLLOWED_TAGS, + tag + ) + viewModel.setFollowingProvider(null) + } + } + ) { + Icon( + imageVector = Icons.Rounded.PersonOff, + contentDescription = "Unfollow artist", + tint = MaterialTheme.colorScheme.primary + ) + } + } + ) + } + } + } +} 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 42d5c18c..f335d246 100644 --- a/app/src/main/java/moe/apex/breadboard/home/HomeScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/home/HomeScreen.kt @@ -1,5 +1,12 @@ package moe.apex.breadboard.home +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.shrinkHorizontally import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -17,15 +24,16 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Person import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FilledIconButton import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.ToggleButton @@ -49,6 +57,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.navigation.NavController import kotlinx.coroutines.Dispatchers @@ -59,6 +68,7 @@ import moe.apex.breadboard.preferences.Experiment import moe.apex.breadboard.preferences.ImageSource import moe.apex.breadboard.preferences.LocalPreferences import moe.apex.breadboard.largeimageview.OffsetBasedLargeImageView +import moe.apex.breadboard.navigation.FollowedArtists import moe.apex.breadboard.preferences.BrowseTab import moe.apex.breadboard.preferences.PreferenceKeys import moe.apex.breadboard.prefs @@ -71,7 +81,6 @@ import moe.apex.breadboard.util.FollowingProvider import moe.apex.breadboard.util.LARGE_SPACER import moe.apex.breadboard.util.ListItemPosition import moe.apex.breadboard.util.MEDIUM_LARGE_SPACER -import moe.apex.breadboard.util.MEDIUM_SPACER import moe.apex.breadboard.util.MainScreenScaffold import moe.apex.breadboard.util.RecommendationsHelper import moe.apex.breadboard.util.RecommendationsProvider @@ -156,15 +165,44 @@ fun HomeScreen( Row( modifier = Modifier .background(color = BreadboardTheme.colors.titleBar) - .padding(horizontal = SMALL_LARGE_SPACER.dp, vertical = TINY_SPACER.dp), - horizontalArrangement = Arrangement.spacedBy(SMALL_SPACER.dp) - ) { - BrowseTab.entries.forEachIndexed { index, tab -> - TagPageIndicator( - label = tab.label, - selected = pagerState.currentPage == index, - onClick = { scope.launch { pagerState.animateScrollToPage(index) } } + .padding( + horizontal = SMALL_LARGE_SPACER.dp, + vertical = TINY_SPACER.dp ) + ) { + Row( + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.spacedBy(SMALL_SPACER.dp) + ) { + BrowseTab.entries.forEachIndexed { index, tab -> + TagPageIndicator( + label = tab.label, + selected = pagerState.currentPage == index, + onClick = { scope.launch { pagerState.animateScrollToPage(index) } } + ) + } + } + AnimatedVisibility( + visible = pagerState.currentPage == BrowseTab.entries.indexOf(BrowseTab.FOLLOWING), + enter = expandHorizontally( + clip = false, + animationSpec = MaterialTheme.motionScheme.slowSpatialSpec(), + )+ fadeIn() + scaleIn( + animationSpec = MaterialTheme.motionScheme.slowSpatialSpec() + ), + exit = shrinkHorizontally() + fadeOut() + scaleOut() + ) { + FilledIconButton( + modifier = Modifier.padding(start = TINY_SPACER.dp), + onClick = { + navController.navigate(FollowedArtists) + } + ) { + Icon( + imageVector = Icons.Rounded.Person, + contentDescription = "Edit followed artists", + ) + } } } } @@ -459,14 +497,16 @@ private fun ArtistSuggestions( verticalArrangement = Arrangement.spacedBy(TINY_SPACER.dp) ) { Text( + text = "Welcome to your Following feed!", style = MaterialTheme.typography.prefTitle, color = MaterialTheme.colorScheme.primary, - text = "Welcome to your Following feed!", + textAlign = TextAlign.Center ) Text( text = "Let's get started by following some of your favourite artists.", style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center ) FlowRow( @@ -490,24 +530,18 @@ private fun ArtistSuggestions( } } - Row( - horizontalArrangement = Arrangement.spacedBy(MEDIUM_SPACER.dp) - ) { - OutlinedButton( - onClick = { - onFollow(suggestedArtists) - } - ) { - Text("Follow all") + Button( + onClick = { + onFollow(selectedForFollow.takeIf { it.isNotEmpty() } ?: suggestedArtists) } - Button( - enabled = selectedForFollow.isNotEmpty(), - onClick = { - onFollow(selectedForFollow) + ) { + Text( + text = if (selectedForFollow.isEmpty()) { + "Follow all" + } else { + "Follow selected" } - ) { - Text("Follow selected") - } + ) } } } diff --git a/app/src/main/java/moe/apex/breadboard/navigation/Destinations.kt b/app/src/main/java/moe/apex/breadboard/navigation/Destinations.kt index 7d83e53b..57aac144 100644 --- a/app/src/main/java/moe/apex/breadboard/navigation/Destinations.kt +++ b/app/src/main/java/moe/apex/breadboard/navigation/Destinations.kt @@ -81,6 +81,9 @@ data class ImageView( @Serializable object Home +@Serializable +object FollowedArtists + @Serializable object Search diff --git a/app/src/main/java/moe/apex/breadboard/navigation/Navigation.kt b/app/src/main/java/moe/apex/breadboard/navigation/Navigation.kt index 98a5135b..8409f29d 100644 --- a/app/src/main/java/moe/apex/breadboard/navigation/Navigation.kt +++ b/app/src/main/java/moe/apex/breadboard/navigation/Navigation.kt @@ -60,6 +60,7 @@ import moe.apex.breadboard.preferences.PreferencesScreen import moe.apex.breadboard.preferences.RecommendationsSettingsScreen import moe.apex.breadboard.ui.theme.BreadboardTheme import moe.apex.breadboard.util.withoutVertical +import moe.apex.breadboard.home.FollowedArtistsScreen @Composable @@ -84,6 +85,7 @@ fun Navigation(navController: NavHostController, startDestination: Any = Search) val popExitTransition = slideOutHorizontally(tween(easing = easing)) { slideDistance } + fadeOut( tween(easing = easing)) val popEnterTransition = slideInHorizontally(tween(easing = easing)) { -slideDistance } + fadeIn(tween(easing = easing)) + val homeScreens = listOf(Home::class, FollowedArtists::class) val searchScreens = listOf(Search::class, Results::class, ArtistProfile::class) val settingsScreens = listOf( Settings::class, @@ -98,8 +100,8 @@ fun Navigation(navController: NavHostController, startDestination: Any = Search) RecommendationsSettings::class, IgnoredTagsSettings::class ) - val topLevelScreens = listOf(Home::class, Search::class, Favourites::class) + settingsScreens - val slideTransitionScreens = listOf(Results::class, ImageView::class, ArtistProfile::class, *settingsScreens.filter { it != Settings::class }.toTypedArray()) + val topLevelScreens = listOf(Home::class, Search::class, Favourites::class, FollowedArtists::class) + settingsScreens + val slideTransitionScreens = listOf(Results::class, ImageView::class, ArtistProfile::class, FollowedArtists::class, *settingsScreens.filter { it != Settings::class }.toTypedArray()) /* Some screens have the ability to hide the bottom bar, so we need to ensure it appears again when navigating to a different screen. */ @@ -120,18 +122,24 @@ fun Navigation(navController: NavHostController, startDestination: Any = Search) NavigationBar(containerColor = BreadboardTheme.colors.titleBar) { NavigationBarItem( label = { Text("Browse") }, - selected = currentRoute.routeIs(Home::class), + selected = currentRoute.routeIs(homeScreens), icon = { Icon( - painter = painterResource(if (currentRoute.routeIs(Home::class)) R.drawable.ic_home_filled else R.drawable.ic_home_hollow), + painter = painterResource(if (currentRoute.routeIs(homeScreens)){ + R.drawable.ic_home_filled + } else { + R.drawable.ic_home_hollow + }), contentDescription = "Browse" ) }, onClick = { - if (!currentRoute.routeIs(Home::class)) { + if (!currentRoute.routeIs(homeScreens)) { navController.navigate(Home) { popUpTo(Home) { inclusive = true } } + } else { + navController.popBackStack() } } ) @@ -248,6 +256,7 @@ fun Navigation(navController: NavHostController, startDestination: Any = Search) val args = it.toRoute() ArtistProfileScreen(args.artistTag, navController = navController) } + composable { FollowedArtistsScreen(navController) } } } } 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 ef477b07..de655b56 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/Preferences.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/Preferences.kt @@ -3,7 +3,6 @@ package moe.apex.breadboard.preferences import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box -import androidx.core.graphics.ColorUtils import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -36,7 +35,6 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.unit.dp @@ -52,6 +50,7 @@ import moe.apex.breadboard.util.LazyExpressiveGroup import moe.apex.breadboard.util.MainScreenScaffold import moe.apex.breadboard.util.MEDIUM_SPACER import moe.apex.breadboard.util.TitleSummary +import moe.apex.breadboard.util.generateColours @OptIn(ExperimentalMaterial3Api::class) @@ -180,42 +179,22 @@ private fun ContainedIcon( label: String, imageVector: ImageVector ) { - val isDark = isSystemInDarkTheme() - // Assign the icon a random colour hue based on its label. - val seed = label.hashCode() - val hue = (seed and Integer.MAX_VALUE).rem(360).toFloat() - - /* We're going for light backgrounds with a darker inner icon. - The specific saturation and lightness depend on whether the user is in dark or light mode. */ - val (bgSat, bgLight) = if (isDark) { - 0.42f to 0.70f - } else { - 0.65f to 0.87f - } - - val (iconSat, iconLight) = if (isDark) { - 0.60f to 0.20f - } else { - 0.65f to 0.27f + val darkTheme = isSystemInDarkTheme() + val colourPair = remember(label) { + generateColours(darkTheme, label) } - val bgInt = ColorUtils.HSLToColor(floatArrayOf(hue, bgSat, bgLight)) - val iconInt = ColorUtils.HSLToColor(floatArrayOf(hue, iconSat, iconLight)) - - val bgColour = Color(bgInt) - val iconTint = Color(iconInt) - Box( modifier = Modifier .clip(CircleShape) - .background(bgColour) + .background(colourPair.first) .fillMaxSize(), contentAlignment = Alignment.Center ) { Icon( imageVector = imageVector, contentDescription = null, - tint = iconTint + tint = colourPair.second ) } } 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 5d915d11..cdef2a1f 100644 --- a/app/src/main/java/moe/apex/breadboard/util/Ui.kt +++ b/app/src/main/java/moe/apex/breadboard/util/Ui.kt @@ -111,6 +111,7 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.layer.drawLayer import androidx.compose.ui.graphics.rememberGraphicsLayer @@ -129,6 +130,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.content.getSystemService +import androidx.core.graphics.ColorUtils import androidx.navigation.NavController import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -908,6 +910,7 @@ fun ExpressiveTagEntryContainer( modifier: Modifier = Modifier, label: String, supportingLabel: String? = null, + leadingContent: (@Composable () -> Unit)? = null, trailingContent: (@Composable RowScope.() -> Unit)? = null, position: ListItemPosition, onClick: (() -> Unit)? = null @@ -928,6 +931,15 @@ fun ExpressiveTagEntryContainer( .clickable(onClick != null) { onClick!!() }, verticalAlignment = Alignment.CenterVertically ) { + leadingContent?.let { + Box( + modifier = Modifier + .padding(start = SMALL_LARGE_SPACER.dp) + .size(40.dp) + ) { + it() + } + } Column( verticalArrangement = Arrangement.Center, modifier = Modifier @@ -938,6 +950,7 @@ fun ExpressiveTagEntryContainer( text = label, fontSize = 16.sp, lineHeight = 17.sp, + maxLines = 1, overflow = TextOverflow.Ellipsis ) @@ -946,6 +959,7 @@ fun ExpressiveTagEntryContainer( text = it, fontSize = 12.sp, lineHeight = 13.sp, + maxLines = 1, overflow = TextOverflow.Ellipsis ) } @@ -1545,6 +1559,38 @@ fun WideLinearWavyProgressIndicator(modifier: Modifier = Modifier) { } +fun generateColours( + darkTheme: Boolean, + seed: Any +): Pair { + // Generate a base hue from on the seed. + val seed = seed.hashCode() + val hue = (seed and Integer.MAX_VALUE).rem(360).toFloat() + + /* We're going for light backgrounds with a darker inner icon. + The specific saturation and lightness depend on darkTheme. */ + val (bgSat, bgLight) = if (darkTheme) { + 0.42f to 0.70f + } else { + 0.65f to 0.87f + } + + val (iconSat, iconLight) = if (darkTheme) { + 0.60f to 0.20f + } else { + 0.65f to 0.27f + } + + val bgInt = ColorUtils.HSLToColor(floatArrayOf(hue, bgSat, bgLight)) + val iconInt = ColorUtils.HSLToColor(floatArrayOf(hue, iconSat, iconLight)) + + val bgColour = Color(bgInt) + val iconTint = Color(iconInt) + + return bgColour to iconTint +} + + val bottomAppBarAndNavBarHeight: Dp @Composable get() = BOTTOM_APP_BAR_HEIGHT.dp + navBarHeight From 1b13c45c5d9672959690c8945e1a7f8973d84ee2 Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Sat, 4 Jul 2026 02:42:11 +0100 Subject: [PATCH 11/59] 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 ae077e37..40251384 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.06.00" -material3 = "1.5.0-alpha22" +compose-bom = "2026.06.01" +material3 = "1.5.0-alpha23" documentfile = "1.1.0" navigation-compose = "2.9.8" paging-compose = "3.5.0" From 54646939fcaef988fae1f6b76e15f663f47574bd Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Sat, 4 Jul 2026 02:50:22 +0100 Subject: [PATCH 12/59] Separate options in content settings --- .../preferences/ContentSettingsScreen.kt | 55 ++++++++++--------- 1 file changed, 29 insertions(+), 26 deletions(-) diff --git a/app/src/main/java/moe/apex/breadboard/preferences/ContentSettingsScreen.kt b/app/src/main/java/moe/apex/breadboard/preferences/ContentSettingsScreen.kt index 9a0d3b1f..9ca37fe2 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/ContentSettingsScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/ContentSettingsScreen.kt @@ -54,16 +54,6 @@ fun ContentSettingsScreen(navController: NavHostController) { contentPadding = PaddingValues(MEDIUM_SPACER.dp), ) { LazyExpressiveGroup(desiredTopPadding = null) { - item { - TitleSummary( - modifier = Modifier.fillMaxWidth(), - title = "Manage blocked tags", - summary = "Add or remove tags to block from search results and recommendations.", - trailingIcon = { ChevronRight() } - ) { - navController.navigate(BlockedTagsSettings) - } - } item { TitleSummary( modifier = Modifier.fillMaxWidth(), @@ -74,22 +64,6 @@ fun ContentSettingsScreen(navController: NavHostController) { navController.navigate(RecommendationsSettings) } } - item { - SwitchPref( - checked = currentSettings.excludeAi, - title = "Hide AI-generated images", - summary = "Attempt to hide AI-generated images by automatically adding " + - "AI-related tags to your block list." - ) { - scope.launch { - preferencesRepository.updatePref( - PreferenceKeys.EXCLUDE_AI, - it - ) - } - viewModel.resetProviders() - } - } item { EnumPref( title = "Automatically play videos", @@ -113,6 +87,35 @@ fun ContentSettingsScreen(navController: NavHostController) { } } + LazyExpressiveGroup("Filtering") { + item { + TitleSummary( + modifier = Modifier.fillMaxWidth(), + title = "Manage blocked tags", + summary = "Add or remove tags to block from search results and recommendations.", + trailingIcon = { ChevronRight() } + ) { + navController.navigate(BlockedTagsSettings) + } + } + item { + SwitchPref( + checked = currentSettings.excludeAi, + title = "Hide AI-generated images", + summary = "Attempt to hide AI-generated images by automatically adding " + + "AI-related tags to your block list." + ) { + scope.launch { + preferencesRepository.updatePref( + PreferenceKeys.EXCLUDE_AI, + it + ) + } + viewModel.resetProviders() + } + } + } + LazyExpressiveGroup("Advanced") { item { SwitchPref( From 16dd42f682af6cd6d45f7ba731b625e738c5b647 Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Sat, 4 Jul 2026 03:35:13 +0100 Subject: [PATCH 13/59] Add option to view profiles for all tags, use user preference source when possible --- .../moe/apex/breadboard/DeepLinkActivity.kt | 13 +++---- .../java/moe/apex/breadboard/MainActivity.kt | 6 ++-- .../breadboard/artist/ArtistProfileScreen.kt | 5 +-- .../breadboard/home/FollowedArtistsScreen.kt | 3 +- .../breadboard/largeimageview/InfoSheet.kt | 17 ++++----- .../breadboard/navigation/Destinations.kt | 7 ++-- .../apex/breadboard/navigation/Navigation.kt | 2 +- .../preferences/ContentSettingsScreen.kt | 36 ++++++++++++++----- .../moe/apex/breadboard/preferences/Pref.kt | 9 ++++- 9 files changed, 65 insertions(+), 33 deletions(-) diff --git a/app/src/main/java/moe/apex/breadboard/DeepLinkActivity.kt b/app/src/main/java/moe/apex/breadboard/DeepLinkActivity.kt index 925ab0f3..ddba2a4c 100644 --- a/app/src/main/java/moe/apex/breadboard/DeepLinkActivity.kt +++ b/app/src/main/java/moe/apex/breadboard/DeepLinkActivity.kt @@ -29,6 +29,7 @@ import kotlinx.coroutines.runBlocking import moe.apex.breadboard.navigation.ArtistProfile import moe.apex.breadboard.navigation.ImageView import moe.apex.breadboard.navigation.Navigation +import moe.apex.breadboard.preferences.ImageSource import moe.apex.breadboard.preferences.LocalPreferences import moe.apex.breadboard.util.FlagSecureHelper import moe.apex.breadboard.util.createViewIntent @@ -86,7 +87,7 @@ class DeepLinkActivity : SingletonImageLoader.Factory, ComponentActivity(), Volu val uri = newIntent.data ?: return@Consumer if (uri.scheme == "breadboard") { - handleBreadboardScheme(uri)?.let { + handleBreadboardScheme(uri, prefs.imageSource)?.let { navController.popBackStack() navController.navigate(it) } ?: finishAndRemoveTask() @@ -94,7 +95,7 @@ class DeepLinkActivity : SingletonImageLoader.Factory, ComponentActivity(), Volu ImageView.fromUri(uri)?.let { navController.popBackStack() navController.navigate(it) - } ?: ArtistProfile.fromUri(uri)?.let { + } ?: ArtistProfile.fromUri(uri, prefs.imageSource)?.let { navController.popBackStack() navController.navigate(it) } ?: reopenInBrowser(newIntent) @@ -105,13 +106,13 @@ class DeepLinkActivity : SingletonImageLoader.Factory, ComponentActivity(), Volu } intent.data?.let { if (it.scheme == "breadboard") { - handleBreadboardScheme(it)?.let { dest -> + handleBreadboardScheme(it, prefs.imageSource)?.let { dest -> Navigation(navController = navController, startDestination = dest) } ?: finishAndRemoveTask() } else { ImageView.fromUri(it)?.let { iv -> Navigation(navController = navController, startDestination = iv) - } ?: ArtistProfile.fromUri(it)?.let { ap -> + } ?: ArtistProfile.fromUri(it, prefs.imageSource)?.let { ap -> Navigation(navController = navController, startDestination = ap) } ?: reopenInBrowser(intent) } @@ -121,11 +122,11 @@ class DeepLinkActivity : SingletonImageLoader.Factory, ComponentActivity(), Volu } - private fun handleBreadboardScheme(uri: Uri): Any? { + private fun handleBreadboardScheme(uri: Uri, imageSource: ImageSource): Any? { val path = uri.pathSegments if (uri.host == "artist") { path.getOrNull(0)?.let { - return ArtistProfile(it) + return ArtistProfile(it, imageSource) } } return null diff --git a/app/src/main/java/moe/apex/breadboard/MainActivity.kt b/app/src/main/java/moe/apex/breadboard/MainActivity.kt index 4cf3bca9..b3ad6037 100644 --- a/app/src/main/java/moe/apex/breadboard/MainActivity.kt +++ b/app/src/main/java/moe/apex/breadboard/MainActivity.kt @@ -81,9 +81,9 @@ class MainActivity : SingletonImageLoader.Factory, ComponentActivity(), VolumeBu private fun maybePrepareArtistDestination(intent: Intent): ArtistProfile? { - return intent.getStringExtra("artist")?.let { - return ArtistProfile(it) - } + val artist = intent.getStringExtra("artist") ?: return null + val source = ImageSource.valueOf(intent.getStringExtra("origin_source") ?: return null) + return ArtistProfile(artist, source) } diff --git a/app/src/main/java/moe/apex/breadboard/artist/ArtistProfileScreen.kt b/app/src/main/java/moe/apex/breadboard/artist/ArtistProfileScreen.kt index d192b032..a57cb149 100644 --- a/app/src/main/java/moe/apex/breadboard/artist/ArtistProfileScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/artist/ArtistProfileScreen.kt @@ -119,6 +119,7 @@ import kotlin.random.Random @Composable fun ArtistProfileScreen( artistTag: String, + originImageSource: ImageSource, viewModel: ArtistProfileViewModel = viewModel(key = artistTag) { ArtistProfileViewModel(artistTag) }, navController: NavController ) { @@ -144,7 +145,7 @@ fun ArtistProfileScreen( if (artist == null) { showToast(context, "Couldn't fetch profile. Performing search instead...") navController.popBackStack() - navController.navigate(Results(ImageSource.DANBOORU, listOf(artistTag))) + navController.navigate(Results(originImageSource, listOf(artistTag))) } } @@ -167,7 +168,7 @@ fun ArtistProfileScreen( ) }, onClick = { - navController.navigate(Results(ImageSource.DANBOORU, listOf(artistTag))) + navController.navigate(Results(originImageSource, listOf(artistTag))) } ) } diff --git a/app/src/main/java/moe/apex/breadboard/home/FollowedArtistsScreen.kt b/app/src/main/java/moe/apex/breadboard/home/FollowedArtistsScreen.kt index 8ea677f6..2574b5bb 100644 --- a/app/src/main/java/moe/apex/breadboard/home/FollowedArtistsScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/home/FollowedArtistsScreen.kt @@ -38,6 +38,7 @@ import androidx.compose.ui.unit.sp import androidx.navigation.NavHostController import kotlinx.coroutines.launch import moe.apex.breadboard.navigation.ArtistProfile +import moe.apex.breadboard.preferences.ImageSource import moe.apex.breadboard.preferences.LocalPreferences import moe.apex.breadboard.preferences.PreferenceKeys import moe.apex.breadboard.prefs @@ -150,7 +151,7 @@ fun FollowedArtistsScreen(navController: NavHostController) { } }, onClick = { - navController.navigate(ArtistProfile(tag)) + navController.navigate(ArtistProfile(tag, ImageSource.DANBOORU)) }, trailingContent = { IconButton( 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 09e28445..89447162 100644 --- a/app/src/main/java/moe/apex/breadboard/largeimageview/InfoSheet.kt +++ b/app/src/main/java/moe/apex/breadboard/largeimageview/InfoSheet.kt @@ -160,19 +160,19 @@ fun InfoSheet(navController: NavController, image: Image, onDismissRequest: () - var selectedTag: String? by remember { mutableStateOf(null) } var selectedTagCategory: TagCategory? by remember { mutableStateOf(null) } - fun startTagSearch(tag: String, category: TagCategory) { + fun startTagSearch(tag: String, category: TagCategory, artistProfileForNonArtistTags: Boolean = false) { hideAndThen { /* Don't do new interactions inside the DeepLinkActivity. We should only ever do them inside the main one. */ if (context is DeepLinkActivity) { - val intent = if (category == TagCategory.ARTIST) { - createArtistIntent(context, tag) + val intent = if (category == TagCategory.ARTIST || artistProfileForNonArtistTags) { + createArtistIntent(context, tag, image.imageSource) } else { createSearchIntent(context, image.imageSource, tag) } context.startActivity(intent) - } else if (category == TagCategory.ARTIST) { - navController.navigate(ArtistProfile(tag)) + } else if (category == TagCategory.ARTIST || artistProfileForNonArtistTags) { + navController.navigate(ArtistProfile(tag, image.imageSource)) } else { navController.navigate(Results(image.imageSource, listOf(tag))) } @@ -216,7 +216,7 @@ fun InfoSheet(navController: NavController, image: Image, onDismissRequest: () - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { ButtonListItem( - label = if (selectedTagCategory == TagCategory.ARTIST) "View profile" else "Search", + label = if (selectedTagCategory == TagCategory.ARTIST || prefs.profilesForAllTags) "View profile" else "Search", icon = Icons.Rounded.Search, modifier = Modifier.fillMaxWidth(), position = ListItemPosition.TOP @@ -225,7 +225,7 @@ fun InfoSheet(navController: NavController, image: Image, onDismissRequest: () - val category = selectedTagCategory!! selectedTag = null selectedTagCategory = null - startTagSearch(searchTag, category) + startTagSearch(searchTag, category, artistProfileForNonArtistTags = prefs.profilesForAllTags) } ButtonListItem( label = "Copy to clipboard", @@ -838,9 +838,10 @@ private fun createSearchIntent(context: Context, imageSource: ImageSource, query } -private fun createArtistIntent(context: Context, artistTag: String): Intent { +private fun createArtistIntent(context: Context, artistTag: String, originImageSource: ImageSource): Intent { return createMainActivityIntent(context, "artist") .putExtra("artist", artistTag) + .putExtra("origin_source", originImageSource.name) } diff --git a/app/src/main/java/moe/apex/breadboard/navigation/Destinations.kt b/app/src/main/java/moe/apex/breadboard/navigation/Destinations.kt index 57aac144..62914b3b 100644 --- a/app/src/main/java/moe/apex/breadboard/navigation/Destinations.kt +++ b/app/src/main/java/moe/apex/breadboard/navigation/Destinations.kt @@ -131,17 +131,18 @@ object IgnoredTagsSettings @Serializable data class ArtistProfile( - val artistTag: String + val artistTag: String, + val originImageSource: ImageSource, ) { companion object { - fun fromUri(uri: Uri): ArtistProfile? { + fun fromUri(uri: Uri, imageSource: ImageSource): ArtistProfile? { val host = uri.host ?: return null val path = uri.pathSegments ?: return null if (host == "breadboard.moe" && path.firstOrNull() == "artist") { val tag = path.getOrNull(1)?.takeIf { it.isNotEmpty() } if (tag != null) { - return ArtistProfile(tag) + return ArtistProfile(tag, imageSource) } } return null diff --git a/app/src/main/java/moe/apex/breadboard/navigation/Navigation.kt b/app/src/main/java/moe/apex/breadboard/navigation/Navigation.kt index 8409f29d..3301e5f6 100644 --- a/app/src/main/java/moe/apex/breadboard/navigation/Navigation.kt +++ b/app/src/main/java/moe/apex/breadboard/navigation/Navigation.kt @@ -254,7 +254,7 @@ fun Navigation(navController: NavHostController, startDestination: Any = Search) composable { IgnoredTagsScreen(navController) } composable { val args = it.toRoute() - ArtistProfileScreen(args.artistTag, navController = navController) + ArtistProfileScreen(args.artistTag, args.originImageSource, navController = navController) } composable { FollowedArtistsScreen(navController) } } diff --git a/app/src/main/java/moe/apex/breadboard/preferences/ContentSettingsScreen.kt b/app/src/main/java/moe/apex/breadboard/preferences/ContentSettingsScreen.kt index 9ca37fe2..154e89b5 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/ContentSettingsScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/ContentSettingsScreen.kt @@ -122,15 +122,15 @@ fun ContentSettingsScreen(navController: NavHostController) { checked = currentSettings.filterRatingsLocally, title = "Filter ratings locally", summary = "Rather than appending the selected ratings to the search query, " + - "filter the results by rating after searching.", + "filter the results by rating after searching.", infoText = "Danbooru limits searches to 2 tags " + - "(which includes ratings) without an API key.\n\n" + - "Enabling this option will allow you to filter by rating " + - "on all sources without an API key, and also let you filter " + - "ratings mid-search, but may cause less results to be shown " + - "at once, resulting in slightly higher data usage.\n\n" + - "Yande.re always requires this option.\n\n" + - "If you're unsure, keep this option enabled." + "(which includes ratings) without an API key.\n\n" + + "Enabling this option will allow you to filter by rating " + + "on all sources without an API key, and also let you filter " + + "ratings mid-search, but may cause less results to be shown " + + "at once, resulting in slightly higher data usage.\n\n" + + "Yande.re always requires this option.\n\n" + + "If you're unsure, keep this option enabled." ) { scope.launch { preferencesRepository.updatePref( @@ -140,6 +140,26 @@ fun ContentSettingsScreen(navController: NavHostController) { } } } + item { + SwitchPref( + checked = currentSettings.profilesForAllTags, + title = "Allow profiles for all tags", + summary = "Replace the search option in the tag menu with an option to " + + "view its profile.", + infoText = "Not all artists are categorised correctly by the image " + + "boards, meaning the profile option would normally be " + + "unavailable for their tag.\n\n" + + "This option forces the profile option to appear for every " + + "tag, even non-artist ones." + ) { + scope.launch { + preferencesRepository.updatePref( + PreferenceKeys.PROFILES_FOR_ALL_TAGS, + it + ) + } + } + } } } } 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 65d55635..c6defeec 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/Pref.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/Pref.kt @@ -99,6 +99,7 @@ data object PrefNames { const val UNIFIED_INFO_SHEET = "unified_info_sheet" const val FOLLOWED_TAGS = "followed_tags" const val DEFAULT_BROWSE_TAB = "default_browse_tab" + const val PROFILES_FOR_ALL_TAGS = "profiles_for_all_tags" } @@ -134,6 +135,7 @@ object PreferenceKeys { val UNIFIED_INFO_SHEET = booleanPreferencesKey(PrefNames.UNIFIED_INFO_SHEET) val FOLLOWED_TAGS = stringSetPreferencesKey(PrefNames.FOLLOWED_TAGS) val DEFAULT_BROWSE_TAB = stringPreferencesKey(PrefNames.DEFAULT_BROWSE_TAB) + val PROFILES_FOR_ALL_TAGS = booleanPreferencesKey(PrefNames.PROFILES_FOR_ALL_TAGS) } @@ -238,6 +240,7 @@ data class Prefs( val unifiedInfoSheet: Boolean, val followedTags: Set, val defaultBrowseTab: BrowseTab, + val profilesForAllTags: Boolean, ) { companion object { val DEFAULT = Prefs( @@ -272,6 +275,7 @@ data class Prefs( unifiedInfoSheet = false, // Unified is called 'Classic' in the UI followedTags = emptySet(), defaultBrowseTab = BrowseTab.FOR_YOU, + profilesForAllTags = false, ) } @@ -336,7 +340,8 @@ class UserPreferencesRepository(private val dataStore: DataStore) { PreferenceKeys.INTERNAL_IGNORE_LIST to PrefMeta(PrefCategory.SETTING, exportable = false), PreferenceKeys.UNIFIED_INFO_SHEET to PrefMeta(PrefCategory.SETTING), PreferenceKeys.FOLLOWED_TAGS to PrefMeta(PrefCategory.SETTING, mergeable = true), - PreferenceKeys.DEFAULT_BROWSE_TAB to PrefMeta(PrefCategory.SETTING) + PreferenceKeys.DEFAULT_BROWSE_TAB to PrefMeta(PrefCategory.SETTING), + PreferenceKeys.PROFILES_FOR_ALL_TAGS to PrefMeta(PrefCategory.SETTING), ) } @@ -836,6 +841,7 @@ class UserPreferencesRepository(private val dataStore: DataStore) { val unifiedInfoSheet = preferences[PreferenceKeys.UNIFIED_INFO_SHEET] ?: Prefs.DEFAULT.unifiedInfoSheet val followedTags = preferences[PreferenceKeys.FOLLOWED_TAGS] ?: Prefs.DEFAULT.followedTags val defaultBrowseTab = preferences[PreferenceKeys.DEFAULT_BROWSE_TAB]?.let { BrowseTab.valueOf(it) } ?: Prefs.DEFAULT.defaultBrowseTab + val profilesForAllTags = preferences[PreferenceKeys.PROFILES_FOR_ALL_TAGS] ?: Prefs.DEFAULT.profilesForAllTags return Prefs( dataSaver, @@ -869,6 +875,7 @@ class UserPreferencesRepository(private val dataStore: DataStore) { unifiedInfoSheet, followedTags, defaultBrowseTab, + profilesForAllTags, ) } } From fe684cf48a2d42752c1e9bc0a978b27025b44c0f Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Sat, 4 Jul 2026 03:51:22 +0100 Subject: [PATCH 14/59] Disallow following and blocking simultaneously --- .../breadboard/artist/ArtistProfileScreen.kt | 17 ++++++++++++++++- .../breadboard/preferences/BlockedTagsScreen.kt | 7 +++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/moe/apex/breadboard/artist/ArtistProfileScreen.kt b/app/src/main/java/moe/apex/breadboard/artist/ArtistProfileScreen.kt index a57cb149..5b2530cc 100644 --- a/app/src/main/java/moe/apex/breadboard/artist/ArtistProfileScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/artist/ArtistProfileScreen.kt @@ -254,6 +254,7 @@ private fun ArtistToolbar(artistTag: String, ) { horizontalArrangement = Arrangement.spacedBy(SMALL_SPACER.dp) ) { val isFollowing = artistTag in prefs.followedTags + val isBlocked = artistTag in prefs.blockedTags ButtonGroup( overflowIndicator = { }, @@ -266,6 +267,7 @@ private fun ArtistToolbar(artistTag: String, ) { ToggleButton( interactionSource = interactionSource, checked = isFollowing, + enabled = !isBlocked, modifier = Modifier .height(48.dp) .weight(3f) @@ -309,7 +311,7 @@ private fun ArtistToolbar(artistTag: String, ) { ) ) { Text( - text = if (!isFollowing) "Follow" else "Following" + text = if (isBlocked) "Blocked" else if (!isFollowing) "Follow" else "Following" ) } }, @@ -371,6 +373,8 @@ private fun ArtistToolbar(artistTag: String, ) { clipboard = clipboard, text = "https://breadboard.moe/artist/$artistTag", ) + }.invokeOnCompletion { + showDropdown = false } }, text = { Text("Copy link") }, @@ -389,6 +393,8 @@ private fun ArtistToolbar(artistTag: String, ) { clipboard = clipboard, text = artistTag, ) + }.invokeOnCompletion { + showDropdown = false } }, text = { Text("Copy search tag") }, @@ -412,7 +418,16 @@ private fun ArtistToolbar(artistTag: String, ) { PreferenceKeys.MANUALLY_BLOCKED_TAGS, artistTag ) + if (artistTag in prefs.followedTags) { + preferencesRepository.removeFromSet( + PreferenceKeys.FOLLOWED_TAGS, + artistTag + ) + } } + }.invokeOnCompletion { + viewModel.resetProviders() + showDropdown = false } }, text = { 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 11968aa1..daaec229 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/BlockedTagsScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/BlockedTagsScreen.kt @@ -104,6 +104,13 @@ fun BlockedTagsScreen(navController: NavHostController) { true ) } else { + if (tag in prefs.followedTags) { + userPreferencesRepository.removeFromSet( + PreferenceKeys.FOLLOWED_TAGS, + tag + ) + viewModel.setFollowingProvider(null) + } userPreferencesRepository.addToSet( PreferenceKeys.MANUALLY_BLOCKED_TAGS, tag From df42669562f77c51e1e38d102786aa4f051ac742 Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Sat, 4 Jul 2026 03:55:30 +0100 Subject: [PATCH 15/59] Fixes in info sheet --- .../apex/breadboard/largeimageview/InfoSheet.kt | 14 +++++++++++++- 1 file changed, 13 insertions(+), 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 89447162..31c2f607 100644 --- a/app/src/main/java/moe/apex/breadboard/largeimageview/InfoSheet.kt +++ b/app/src/main/java/moe/apex/breadboard/largeimageview/InfoSheet.kt @@ -225,7 +225,13 @@ fun InfoSheet(navController: NavController, image: Image, onDismissRequest: () - val category = selectedTagCategory!! selectedTag = null selectedTagCategory = null - startTagSearch(searchTag, category, artistProfileForNonArtistTags = prefs.profilesForAllTags) + hideAndThen { + startTagSearch( + tag = searchTag, + category = category, + artistProfileForNonArtistTags = prefs.profilesForAllTags + ) + } } ButtonListItem( label = "Copy to clipboard", @@ -270,6 +276,12 @@ fun InfoSheet(navController: NavController, image: Image, onDismissRequest: () - PreferenceKeys.MANUALLY_BLOCKED_TAGS, selectedTag!! ) + if (selectedTag in prefs.followedTags) { + preferencesRepository.removeFromSet( + PreferenceKeys.FOLLOWED_TAGS, + selectedTag!! + ) + } } } showToast(context, "Blocked tag ${selectedTag!!}") From 758a59d6398e93895cbd0c4c845ca69387d2c01d Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Mon, 6 Jul 2026 01:23:32 +0100 Subject: [PATCH 16/59] Update non-coro LaunchedEffects to new keyed SideEffects --- .../java/moe/apex/breadboard/MainActivity.kt | 4 ++-- .../breadboard/detailview/SearchResults.kt | 4 ++-- .../moe/apex/breadboard/home/HomeScreen.kt | 4 ++-- .../breadboard/largeimageview/InfoSheet.kt | 4 ++-- .../largeimageview/LargeImageView.kt | 19 ++++++++++--------- .../apex/breadboard/navigation/Navigation.kt | 4 ++-- .../apex/breadboard/search/SearchScreen.kt | 5 +++-- .../apex/breadboard/util/FlagSecureHelper.kt | 4 ++-- .../moe/apex/breadboard/util/Modifiers.kt | 4 ++-- 9 files changed, 27 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/moe/apex/breadboard/MainActivity.kt b/app/src/main/java/moe/apex/breadboard/MainActivity.kt index b3ad6037..b87d0ef7 100644 --- a/app/src/main/java/moe/apex/breadboard/MainActivity.kt +++ b/app/src/main/java/moe/apex/breadboard/MainActivity.kt @@ -15,7 +15,7 @@ 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 +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.core.util.Consumer @@ -123,7 +123,7 @@ class MainActivity : SingletonImageLoader.Factory, ComponentActivity(), VolumeBu val viewModel = getGlobalViewModel() val recommendationsProvider by viewModel.recommendationsProvider.collectAsState() - LaunchedEffect(prefs.imageSource, prefs.filterRatingsLocally) { + SideEffect(prefs.imageSource, prefs.filterRatingsLocally) { if ( recommendationsProvider?.imageSource != prefs.imageSource || recommendationsProvider?.filterRatingsLocally != prefs.filterRatingsLocally 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 54b5876d..3ad90c5d 100644 --- a/app/src/main/java/moe/apex/breadboard/detailview/SearchResults.kt +++ b/app/src/main/java/moe/apex/breadboard/detailview/SearchResults.kt @@ -14,7 +14,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -97,7 +97,7 @@ fun SearchResults(navController: NavController, source: ImageSource, tagList: Li fun updateBlockedTags() = viewModel.updateBlockedTags(manuallyBlockedTags, prefs.excludeAi) - LaunchedEffect(Unit) { + SideEffect(Unit) { val auth = prefs.authFor(source, context) if (auth != viewModelAuth) { viewModel.updateAuth(auth) 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 f335d246..8194a1a2 100644 --- a/app/src/main/java/moe/apex/breadboard/home/HomeScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/home/HomeScreen.kt @@ -238,7 +238,7 @@ fun HomeScreen( } } else { if (recommendationsProvider == null) { - LaunchedEffect(Unit) { + SideEffect(Unit) { val newProvider = RecommendationsProvider( seedImages = prefs.favouriteImages, imageSource = prefs.imageSource, @@ -255,7 +255,7 @@ fun HomeScreen( } } if (followingProvider == null) { - LaunchedEffect(Unit) { + SideEffect(Unit) { val newProvider = FollowingProvider( followedArtists = prefs.followedTags, auth = prefs.authFor(ImageSource.DANBOORU, context), 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 31c2f607..6ac58893 100644 --- a/app/src/main/java/moe/apex/breadboard/largeimageview/InfoSheet.kt +++ b/app/src/main/java/moe/apex/breadboard/largeimageview/InfoSheet.kt @@ -51,7 +51,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.rememberBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -150,7 +150,7 @@ fun InfoSheet(navController: NavController, image: Image, onDismissRequest: () - /* The unified sheet should always open in half-expanded state, but should never go back to the half-expanded state when closing. */ if (unified) { - LaunchedEffect(sheetState.targetValue) { + SideEffect(sheetState.targetValue) { if (sheetState.currentValue == SheetValue.Expanded && sheetState.targetValue == SheetValue.PartiallyExpanded) { hideAndThen() } 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 6d918b34..005b16a7 100644 --- a/app/src/main/java/moe/apex/breadboard/largeimageview/LargeImageView.kt +++ b/app/src/main/java/moe/apex/breadboard/largeimageview/LargeImageView.kt @@ -80,6 +80,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -281,14 +282,14 @@ private fun LargeImageView( onImageClick = ::toggleToolbar ) - LaunchedEffect(zoomFractionAllowsPageChange, isFullyZoomedOut, pagerState.currentPage) { + SideEffect(zoomFractionAllowsPageChange, isFullyZoomedOut, pagerState.currentPage) { canChangePage = zoomFractionAllowsPageChange if (isFullyZoomedOut) { toolbarState = ToolbarState.DEFAULT } } - LaunchedEffect(isFullyZoomedOut) { + SideEffect(isFullyZoomedOut) { onZoomedStatusChanged?.invoke(!isFullyZoomedOut) } @@ -939,17 +940,17 @@ fun LargeVideo(image: Image, isCurrentPage: Boolean, onLongClick: (() -> Unit)? /* While most of these run very infrequently or only once, I still don't like this LaunchedEffect hell. */ - LaunchedEffect(Unit) { + SideEffect(Unit) { player.loop = true } - LaunchedEffect(player.isLoading) { + SideEffect(player.isLoading) { if (player.hasMedia && !player.isLoading) { doneInitialLoad = true } } - LaunchedEffect(isHovered) { + SideEffect(isHovered) { showControls = isHovered } @@ -960,15 +961,15 @@ fun LargeVideo(image: Image, isCurrentPage: Boolean, onLongClick: (() -> Unit)? } } - LaunchedEffect(muted) { + SideEffect(muted) { player.volume = if (muted) 0f else 0.5f } - LaunchedEffect(userMutePreference) { + SideEffect(userMutePreference) { userMutePreference?.let { muted = it } } - LaunchedEffect(isCurrentPage) { + SideEffect(isCurrentPage) { if (!isCurrentPage) { player.pause() } else { @@ -1258,7 +1259,7 @@ fun OffsetBasedLargeImageView( } if (isActive) { - LaunchedEffect(Unit) { + SideEffect(Unit) { /* Theoretically breakable if someone spoofs the system clock to never update, that's rather unlikely. */ onActiveStateChanged(true) diff --git a/app/src/main/java/moe/apex/breadboard/navigation/Navigation.kt b/app/src/main/java/moe/apex/breadboard/navigation/Navigation.kt index 3301e5f6..887c8ac8 100644 --- a/app/src/main/java/moe/apex/breadboard/navigation/Navigation.kt +++ b/app/src/main/java/moe/apex/breadboard/navigation/Navigation.kt @@ -21,7 +21,7 @@ import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -105,7 +105,7 @@ fun Navigation(navController: NavHostController, startDestination: Any = Search) /* Some screens have the ability to hide the bottom bar, so we need to ensure it appears again when navigating to a different screen. */ - LaunchedEffect(currentRoute) { + SideEffect(currentRoute) { if (currentRoute.routeIs(topLevelScreens)) { isNavigationBarVisible = true } 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 e84164b2..2711e93e 100644 --- a/app/src/main/java/moe/apex/breadboard/search/SearchScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/search/SearchScreen.kt @@ -65,6 +65,7 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf @@ -495,7 +496,7 @@ fun SearchScreen(navController: NavController, focusRequester: FocusRequester) { ) { var opacity by remember { mutableFloatStateOf(1f) } var scale by remember { mutableFloatStateOf(1f) } - LaunchedEffect(showSourceRatingBox) { + SideEffect(showSourceRatingBox) { if (showSourceRatingBox) { opacity = 1f scale = 1f @@ -586,7 +587,7 @@ fun SearchScreen(navController: NavController, focusRequester: FocusRequester) { bottom = SMALL_SPACER.dp ) ) { - LaunchedEffect(prefs.ratingsFilter) { + SideEffect(prefs.ratingsFilter) { Log.i("Breadboard", "new ratings filter: ${prefs.ratingsFilter}") } diff --git a/app/src/main/java/moe/apex/breadboard/util/FlagSecureHelper.kt b/app/src/main/java/moe/apex/breadboard/util/FlagSecureHelper.kt index c2358053..45fa61f4 100644 --- a/app/src/main/java/moe/apex/breadboard/util/FlagSecureHelper.kt +++ b/app/src/main/java/moe/apex/breadboard/util/FlagSecureHelper.kt @@ -4,7 +4,7 @@ import android.annotation.SuppressLint import android.view.WindowManager import androidx.activity.compose.LocalActivity import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import moe.apex.breadboard.preferences.FlagSecureMode @@ -32,7 +32,7 @@ class FlagSecureHelper { window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) } - LaunchedEffect(prefs.flagSecureMode, incognito) { + SideEffect(prefs.flagSecureMode, incognito) { when (prefs.flagSecureMode) { FlagSecureMode.ON -> enableFlagSecure() FlagSecureMode.OFF -> disableFlagSecure() diff --git a/app/src/main/java/moe/apex/breadboard/util/Modifiers.kt b/app/src/main/java/moe/apex/breadboard/util/Modifiers.kt index ff8be3ab..4e348a2e 100644 --- a/app/src/main/java/moe/apex/breadboard/util/Modifiers.kt +++ b/app/src/main/java/moe/apex/breadboard/util/Modifiers.kt @@ -2,13 +2,13 @@ package moe.apex.breadboard.util import androidx.compose.foundation.gestures.ScrollableState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect import androidx.compose.ui.Modifier @Composable fun Modifier.onScroll(scrollableState: ScrollableState, callback: (ScrollableState) -> Unit): Modifier { - LaunchedEffect(scrollableState.isScrollInProgress, scrollableState.lastScrolledForward) { + SideEffect(scrollableState.isScrollInProgress, scrollableState.lastScrolledForward) { if (scrollableState.isScrollInProgress) { callback(scrollableState) } From 0f6b2cbabba8695c588b81305d545961f2a48c75 Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:41:53 +0100 Subject: [PATCH 17/59] Fix handling pre-2012 pixiv URLs --- .../main/java/moe/apex/breadboard/util/Pixiv.kt | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) 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 fd57a33b..5f4aaba3 100644 --- a/app/src/main/java/moe/apex/breadboard/util/Pixiv.kt +++ b/app/src/main/java/moe/apex/breadboard/util/Pixiv.kt @@ -23,20 +23,32 @@ 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() -private val PIXIV_RX = listOf(PIXIV_CURRENT_RX) + PIXIV_2012_TO_2016_RX + PIXIV_PRE_2012_RX 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) { + /* Pre-2012 Pixiv URLs don't seem to have indexes at all. + It feels better and more intentional/deliberate to handle them specifically, + at least compared to putting a try/catch around the index group. */ + + val indexedRegexes = listOf(PIXIV_CURRENT_RX) + PIXIV_2012_TO_2016_RX + for (regex in indexedRegexes) { 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) } + val pre2012Match = PIXIV_PRE_2012_RX.find(url) + if (pre2012Match != null) { + val id = pre2012Match.groups["id"]?.value?.toIntOrNull().takeIf { it != 0 } + if (id != null) { + return PixivArtwork(id, 0) + } + } + return null } } From 04b505a2cebb38cd8a664759dff446d44f1f699b Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Wed, 5 Aug 2026 16:18:46 +0100 Subject: [PATCH 18/59] Add SauceNAO for reverse lookups --- .../java/moe/apex/breadboard/RequestUtil.kt | 36 +- .../java/moe/apex/breadboard/artist/Artist.kt | 8 +- .../breadboard/artist/ArtistProfileScreen.kt | 4 +- .../java/moe/apex/breadboard/image/Image.kt | 6 +- .../moe/apex/breadboard/image/ImageBoard.kt | 4 +- .../breadboard/navigation/Destinations.kt | 6 + .../apex/breadboard/navigation/Navigation.kt | 35 +- .../preferences/GeneralSettingsScreen.kt | 81 +++ .../moe/apex/breadboard/preferences/Pref.kt | 14 + .../saucenao/ReverseSearchScreen.kt | 331 ++++++++++ .../moe/apex/breadboard/saucenao/SauceNao.kt | 259 ++++++++ .../saucenao/SauceNaoResultsScreen.kt | 570 ++++++++++++++++++ .../ArtistSocial.kt => social/SocialEntry.kt} | 19 +- .../java/moe/apex/breadboard/util/Pixiv.kt | 28 +- .../main/java/moe/apex/breadboard/util/Ui.kt | 73 ++- .../viewmodel/SaucenaoResultsViewModel.kt | 106 ++++ .../res/drawable/ic_image_search_filled.xml | 9 + .../res/drawable/ic_image_search_hollow.xml | 9 + 18 files changed, 1564 insertions(+), 34 deletions(-) create mode 100644 app/src/main/java/moe/apex/breadboard/saucenao/ReverseSearchScreen.kt create mode 100644 app/src/main/java/moe/apex/breadboard/saucenao/SauceNao.kt create mode 100644 app/src/main/java/moe/apex/breadboard/saucenao/SauceNaoResultsScreen.kt rename app/src/main/java/moe/apex/breadboard/{artist/ArtistSocial.kt => social/SocialEntry.kt} (76%) create mode 100644 app/src/main/java/moe/apex/breadboard/viewmodel/SaucenaoResultsViewModel.kt create mode 100644 app/src/main/res/drawable/ic_image_search_filled.xml create mode 100644 app/src/main/res/drawable/ic_image_search_hollow.xml diff --git a/app/src/main/java/moe/apex/breadboard/RequestUtil.kt b/app/src/main/java/moe/apex/breadboard/RequestUtil.kt index 42eea26f..98fb99ab 100644 --- a/app/src/main/java/moe/apex/breadboard/RequestUtil.kt +++ b/app/src/main/java/moe/apex/breadboard/RequestUtil.kt @@ -39,7 +39,41 @@ object RequestUtil { val body = it.body.string() continuation.resume(body) } else { - onFailure(call, IOException("HTTP error code: ${it.code}")) + onFailure(call, IOException("${it.code}")) + } + } + } + } + }) + } + } + + + suspend fun post(url: String, body: RequestBody, apply: Request.Builder.() -> Unit = {}): String = withContext(Dispatchers.IO) { + val req = Request.Builder().url(url).apply(apply).post(body).build() + + suspendCancellableCoroutine { continuation -> + val call = client.newCall(req) + + continuation.invokeOnCancellation { + call.cancel() + } + + call.enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + if (continuation.isActive) { + continuation.resumeWithException(e) + } + } + + override fun onResponse(call: Call, response: Response) { + if (continuation.isActive) { + response.use { + if (it.isSuccessful) { + val responseBody = it.body.string() + continuation.resume(responseBody) + } else { + onFailure(call, IOException("${it.code}")) } } } diff --git a/app/src/main/java/moe/apex/breadboard/artist/Artist.kt b/app/src/main/java/moe/apex/breadboard/artist/Artist.kt index 3510ef6f..33e9f825 100644 --- a/app/src/main/java/moe/apex/breadboard/artist/Artist.kt +++ b/app/src/main/java/moe/apex/breadboard/artist/Artist.kt @@ -1,19 +1,21 @@ package moe.apex.breadboard.artist import androidx.compose.runtime.Immutable +import moe.apex.breadboard.social.SocialEntry +import moe.apex.breadboard.social.SocialSite @Immutable data class Artist( val name: String, val otherNames: List, - val socialUrls: List, + val socialUrls: List, val tag: ArtistTag ) { - fun groupSocials(): Map> { + fun groupSocials(): Map> { return socialUrls.groupBy { SocialSite.fromUrl(it.url) } .toList() - .sortedWith(compareBy>> { + .sortedWith(compareBy>> { when (it.first) { // Site priority, always show Pixiv/Fanbox/Twitter first and OTHER last. SocialSite.PIXIV -> 0 SocialSite.FANBOX -> 1 diff --git a/app/src/main/java/moe/apex/breadboard/artist/ArtistProfileScreen.kt b/app/src/main/java/moe/apex/breadboard/artist/ArtistProfileScreen.kt index 5b2530cc..f26af58d 100644 --- a/app/src/main/java/moe/apex/breadboard/artist/ArtistProfileScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/artist/ArtistProfileScreen.kt @@ -96,6 +96,8 @@ import moe.apex.breadboard.preferences.ImageSource import moe.apex.breadboard.preferences.LocalPreferences import moe.apex.breadboard.preferences.PreferenceKeys import moe.apex.breadboard.prefs +import moe.apex.breadboard.social.SocialEntry +import moe.apex.breadboard.social.SocialSite import moe.apex.breadboard.ui.theme.BreadboardTheme import moe.apex.breadboard.util.LARGE_SPACER import moe.apex.breadboard.util.LargeVerticalSpacer @@ -571,7 +573,7 @@ private fun ArtistHeader( @Composable private fun SocialChip( site: SocialSite, - socialProfiles: List, + socialProfiles: List, onClick: (String) -> Unit) { var showDropdown by remember { mutableStateOf(false) } 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 08829866..a9a9a879 100644 --- a/app/src/main/java/moe/apex/breadboard/image/Image.kt +++ b/app/src/main/java/moe/apex/breadboard/image/Image.kt @@ -45,11 +45,7 @@ data class ImageMetadata( get() = PixivArtwork.fromUrl(source) ?: pixivArtworkId?.let { PixivArtwork(it, 0) } val pixivUrl: String? - get() = pixivArtwork?.let { - val id = if (it.index == 0) it.id.toString() - else "${it.id}#${it.index}" - "https://www.pixiv.net/artworks/$id" - } + get() = pixivArtwork?.toString() } 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 630c4b68..7c116b90 100644 --- a/app/src/main/java/moe/apex/breadboard/image/ImageBoard.kt +++ b/app/src/main/java/moe/apex/breadboard/image/ImageBoard.kt @@ -5,7 +5,7 @@ import kotlinx.coroutines.delay import kotlinx.serialization.Serializable import moe.apex.breadboard.RequestUtil import moe.apex.breadboard.artist.Artist -import moe.apex.breadboard.artist.ArtistSocial +import moe.apex.breadboard.social.SocialEntry import moe.apex.breadboard.artist.ArtistTag import moe.apex.breadboard.preferences.ImageSource import moe.apex.breadboard.tag.TagCategory @@ -521,7 +521,7 @@ object Danbooru : ImageBoard { socialUrls = primaryArtist.optJSONArray("urls")?.let { urlArray -> (0 until urlArray.length()).mapNotNull { index -> val urlEntry = urlArray.optJSONObject(index) - ArtistSocial( + SocialEntry( url = urlEntry.optString("url").takeUnless { it.isBlank() } ?: return@mapNotNull null, isActive = urlEntry.optBoolean("is_active", false) ) diff --git a/app/src/main/java/moe/apex/breadboard/navigation/Destinations.kt b/app/src/main/java/moe/apex/breadboard/navigation/Destinations.kt index 62914b3b..20b099c9 100644 --- a/app/src/main/java/moe/apex/breadboard/navigation/Destinations.kt +++ b/app/src/main/java/moe/apex/breadboard/navigation/Destinations.kt @@ -87,6 +87,9 @@ object FollowedArtists @Serializable object Search +@Serializable +object ReverseSearch + @Serializable data class Results( val source: ImageSource, @@ -129,6 +132,9 @@ object RecommendationsSettings @Serializable object IgnoredTagsSettings +@Serializable +data class SauceNaoResults(val imageUrl: String = "", val fileUri: String = "") + @Serializable data class ArtistProfile( val artistTag: String, diff --git a/app/src/main/java/moe/apex/breadboard/navigation/Navigation.kt b/app/src/main/java/moe/apex/breadboard/navigation/Navigation.kt index 887c8ac8..ea9da07a 100644 --- a/app/src/main/java/moe/apex/breadboard/navigation/Navigation.kt +++ b/app/src/main/java/moe/apex/breadboard/navigation/Navigation.kt @@ -58,6 +58,8 @@ import moe.apex.breadboard.preferences.LayoutSettingsScreen import moe.apex.breadboard.preferences.LibrariesScreen import moe.apex.breadboard.preferences.PreferencesScreen import moe.apex.breadboard.preferences.RecommendationsSettingsScreen +import moe.apex.breadboard.saucenao.SauceNaoResultsScreen +import moe.apex.breadboard.saucenao.ReverseSearchScreen import moe.apex.breadboard.ui.theme.BreadboardTheme import moe.apex.breadboard.util.withoutVertical import moe.apex.breadboard.home.FollowedArtistsScreen @@ -87,6 +89,7 @@ fun Navigation(navController: NavHostController, startDestination: Any = Search) val homeScreens = listOf(Home::class, FollowedArtists::class) val searchScreens = listOf(Search::class, Results::class, ArtistProfile::class) + val reverseSearchScreens = listOf(ReverseSearch::class, SauceNaoResults::class) val settingsScreens = listOf( Settings::class, GeneralSettings::class, @@ -100,8 +103,8 @@ fun Navigation(navController: NavHostController, startDestination: Any = Search) RecommendationsSettings::class, IgnoredTagsSettings::class ) - val topLevelScreens = listOf(Home::class, Search::class, Favourites::class, FollowedArtists::class) + settingsScreens - val slideTransitionScreens = listOf(Results::class, ImageView::class, ArtistProfile::class, FollowedArtists::class, *settingsScreens.filter { it != Settings::class }.toTypedArray()) + val topLevelScreens = listOf(Home::class, Search::class, ReverseSearch::class, Favourites::class, FollowedArtists::class) + settingsScreens + val slideTransitionScreens = listOf(Results::class, ImageView::class, ArtistProfile::class, FollowedArtists::class, SauceNaoResults::class, *settingsScreens.filter { it != Settings::class }.toTypedArray()) /* Some screens have the ability to hide the bottom bar, so we need to ensure it appears again when navigating to a different screen. */ @@ -165,6 +168,29 @@ fun Navigation(navController: NavHostController, startDestination: Any = Search) } } ) + NavigationBarItem( + label = { Text("SauceNAO") }, + selected = currentRoute.routeIs(reverseSearchScreens), + icon = { + Icon( + painter = painterResource( + if (currentRoute.routeIs(reverseSearchScreens)) { + R.drawable.ic_image_search_filled + } else { + R.drawable.ic_image_search_hollow + } + ), + contentDescription = "SauceNAO Reverse image search" + ) + }, + onClick = { + if (!currentRoute.routeIs(ReverseSearch::class)) { + navController.navigate(ReverseSearch) { + popUpTo(ReverseSearch) { inclusive = true } + } + } + } + ) NavigationBarItem( label = { Text("Favourites") }, selected = currentRoute.routeIs(Favourites::class), @@ -236,6 +262,7 @@ fun Navigation(navController: NavHostController, startDestination: Any = Search) } composable { HomeScreen(navController) { isNavigationBarVisible = it } } composable { SearchScreen(navController, focusRequester) } + composable { ReverseSearchScreen(navController) } composable { val args = it.toRoute() SearchResults(navController, args.source, args.tags) @@ -257,6 +284,10 @@ fun Navigation(navController: NavHostController, startDestination: Any = Search) ArtistProfileScreen(args.artistTag, args.originImageSource, navController = navController) } composable { FollowedArtistsScreen(navController) } + composable { + val args = it.toRoute() + SauceNaoResultsScreen(navController, args.imageUrl, args.fileUri) + } } } } diff --git a/app/src/main/java/moe/apex/breadboard/preferences/GeneralSettingsScreen.kt b/app/src/main/java/moe/apex/breadboard/preferences/GeneralSettingsScreen.kt index 09a88c87..13310f7b 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/GeneralSettingsScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/GeneralSettingsScreen.kt @@ -1,12 +1,17 @@ package moe.apex.breadboard.preferences import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth 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.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable @@ -18,6 +23,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController import kotlinx.coroutines.launch @@ -28,6 +34,7 @@ import moe.apex.breadboard.util.LargeTitleBar import moe.apex.breadboard.util.LazyExpressiveGroup import moe.apex.breadboard.util.MainScreenScaffold import moe.apex.breadboard.util.MEDIUM_SPACER +import moe.apex.breadboard.util.SMALL_SPACER import moe.apex.breadboard.util.TitleSummary import moe.apex.breadboard.viewmodel.getGlobalViewModel @@ -42,6 +49,7 @@ fun GeneralSettingsScreen(navController: NavHostController) { var showAuthDialog by remember { mutableStateOf(false) } var showAgeVerificationDialog by remember { mutableStateOf(false) } + var showSauceNaoKeyDialog by remember { mutableStateOf(false) } val preferencesRepository = LocalContext.current.prefs val currentSettings = LocalPreferences.current @@ -71,6 +79,19 @@ fun GeneralSettingsScreen(navController: NavHostController) { ) } + if (showSauceNaoKeyDialog) { + SauceNaoApiKeyDialog( + currentKey = currentSettings.saucenaoApiKey, + onDismissRequest = { showSauceNaoKeyDialog = false }, + onSave = { key -> + scope.launch { + preferencesRepository.updatePref(PreferenceKeys.SAUCENAO_API_KEY, key) + } + showSauceNaoKeyDialog = false + } + ) + } + MainScreenScaffold( topAppBar = { LargeTitleBar( @@ -166,6 +187,66 @@ fun GeneralSettingsScreen(navController: NavHostController) { } } } + + LazyExpressiveGroup("SauceNAO") { + item { + TitleSummary( + modifier = Modifier.fillMaxWidth(), + title = "Set SauceNAO API key", + summary = if (currentSettings.saucenaoApiKey.isNotEmpty()) { + "API key is set." + } else { + "An API key is required to use reverse image search. Tap to set." + } + ) { + showSauceNaoKeyDialog = true + } + } + } } } } + + +@Composable +private fun SauceNaoApiKeyDialog( + currentKey: String, + onDismissRequest: () -> Unit, + onSave: (String) -> Unit +) { + var apiKey by remember { mutableStateOf(currentKey) } + + AlertDialog( + onDismissRequest = onDismissRequest, + title = { Text("SauceNAO API key") }, + text = { + Column { + Text( + text = "Enter your SauceNAO API key. You can get one from saucenao.com/user.php after creating an account.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = SMALL_SPACER.dp) + ) + PreferenceTextBox( + value = apiKey, + label = "API key", + obscured = true, + keyboardType = KeyboardType.Password + ) { + apiKey = it.trim() + } + } + }, + confirmButton = { + TextButton(onClick = { onSave(apiKey) }) { + Text("Save") + } + }, + dismissButton = { + TextButton(onClick = onDismissRequest) { + Text("Cancel") + } + } + ) +} + 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 c6defeec..381bf2bc 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/Pref.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/Pref.kt @@ -100,6 +100,8 @@ data object PrefNames { const val FOLLOWED_TAGS = "followed_tags" const val DEFAULT_BROWSE_TAB = "default_browse_tab" const val PROFILES_FOR_ALL_TAGS = "profiles_for_all_tags" + const val SAUCENAO_API_KEY = "saucenao_api_key" + const val SAUCENAO_ALLOW_NSFW = "saucenao_allow_nsfw" } @@ -136,6 +138,8 @@ object PreferenceKeys { val FOLLOWED_TAGS = stringSetPreferencesKey(PrefNames.FOLLOWED_TAGS) val DEFAULT_BROWSE_TAB = stringPreferencesKey(PrefNames.DEFAULT_BROWSE_TAB) val PROFILES_FOR_ALL_TAGS = booleanPreferencesKey(PrefNames.PROFILES_FOR_ALL_TAGS) + val SAUCENAO_API_KEY = stringPreferencesKey(PrefNames.SAUCENAO_API_KEY) + val SAUCENAO_ALLOW_NSFW = booleanPreferencesKey(PrefNames.SAUCENAO_ALLOW_NSFW) } @@ -241,6 +245,8 @@ data class Prefs( val followedTags: Set, val defaultBrowseTab: BrowseTab, val profilesForAllTags: Boolean, + val saucenaoApiKey: String, + val saucenaoAllowNsfw: Boolean, ) { companion object { val DEFAULT = Prefs( @@ -276,6 +282,8 @@ data class Prefs( followedTags = emptySet(), defaultBrowseTab = BrowseTab.FOR_YOU, profilesForAllTags = false, + saucenaoApiKey = "", + saucenaoAllowNsfw = false, ) } @@ -342,6 +350,8 @@ class UserPreferencesRepository(private val dataStore: DataStore) { PreferenceKeys.FOLLOWED_TAGS to PrefMeta(PrefCategory.SETTING, mergeable = true), PreferenceKeys.DEFAULT_BROWSE_TAB to PrefMeta(PrefCategory.SETTING), PreferenceKeys.PROFILES_FOR_ALL_TAGS to PrefMeta(PrefCategory.SETTING), + PreferenceKeys.SAUCENAO_API_KEY to PrefMeta(PrefCategory.SETTING, exportable = false), + PreferenceKeys.SAUCENAO_ALLOW_NSFW to PrefMeta(PrefCategory.SETTING), ) } @@ -842,6 +852,8 @@ class UserPreferencesRepository(private val dataStore: DataStore) { val followedTags = preferences[PreferenceKeys.FOLLOWED_TAGS] ?: Prefs.DEFAULT.followedTags val defaultBrowseTab = preferences[PreferenceKeys.DEFAULT_BROWSE_TAB]?.let { BrowseTab.valueOf(it) } ?: Prefs.DEFAULT.defaultBrowseTab val profilesForAllTags = preferences[PreferenceKeys.PROFILES_FOR_ALL_TAGS] ?: Prefs.DEFAULT.profilesForAllTags + val saucenaoApiKey = preferences[PreferenceKeys.SAUCENAO_API_KEY] ?: Prefs.DEFAULT.saucenaoApiKey + val saucenaoAllowNsfw = preferences[PreferenceKeys.SAUCENAO_ALLOW_NSFW] ?: Prefs.DEFAULT.saucenaoAllowNsfw return Prefs( dataSaver, @@ -876,6 +888,8 @@ class UserPreferencesRepository(private val dataStore: DataStore) { followedTags, defaultBrowseTab, profilesForAllTags, + saucenaoApiKey, + saucenaoAllowNsfw, ) } } diff --git a/app/src/main/java/moe/apex/breadboard/saucenao/ReverseSearchScreen.kt b/app/src/main/java/moe/apex/breadboard/saucenao/ReverseSearchScreen.kt new file mode 100644 index 00000000..8d9c34dd --- /dev/null +++ b/app/src/main/java/moe/apex/breadboard/saucenao/ReverseSearchScreen.kt @@ -0,0 +1,331 @@ +package moe.apex.breadboard.saucenao + +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Clear +import androidx.compose.material.icons.rounded.ContentPaste +import androidx.compose.material.icons.rounded.Image +import androidx.compose.material.icons.rounded.Search +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.core.net.toUri +import androidx.navigation.NavController +import coil3.compose.AsyncImage +import coil3.network.NetworkHeaders +import coil3.network.httpHeaders +import coil3.request.ImageRequest +import coil3.request.crossfade +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import moe.apex.breadboard.navigation.SauceNaoResults +import moe.apex.breadboard.preferences.LocalPreferences +import moe.apex.breadboard.ui.theme.searchField +import moe.apex.breadboard.util.BasicExpressiveContainer +import moe.apex.breadboard.util.FeaturedImageTitleSummary +import moe.apex.breadboard.util.ListItemPosition +import moe.apex.breadboard.util.SMALL_SPACER +import moe.apex.breadboard.util.TINY_SPACER +import moe.apex.breadboard.util.SMALL_LARGE_SPACER +import moe.apex.breadboard.util.MainScreenScaffold +import moe.apex.breadboard.util.SmallVerticalSpacer +import moe.apex.breadboard.util.isWebLink +import moe.apex.breadboard.util.largerShape +import moe.apex.breadboard.util.showToast + + +@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) +@Composable +fun ReverseSearchScreen(navController: NavController) { + val context = LocalContext.current + val clipboard = LocalClipboard.current + val prefs = LocalPreferences.current + val scope = rememberCoroutineScope() + + var imageUrl by rememberSaveable { mutableStateOf("") } + var selectedFileUri by rememberSaveable { mutableStateOf(null) } + + val imagePickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetContent() + ) { uri: Uri? -> + if (uri != null) { + selectedFileUri = uri + imageUrl = "" // We'll just clear the text field when selecting an image + } + } + + var debouncedImageUrl by remember { mutableStateOf(imageUrl) } + + LaunchedEffect(imageUrl) { + if (imageUrl.isEmpty()) { + debouncedImageUrl = "" + } else { + delay(500) + debouncedImageUrl = imageUrl + } + } + + val isUrlValid = remember(debouncedImageUrl) { debouncedImageUrl.isWebLink() } + var isUrlPreviewLoaded by remember(debouncedImageUrl) { mutableStateOf(false) } + + if (isUrlValid) { + // Mostly intended so we can still preview Gelbooru URLs. TODO: Test if they actually work + val headersBuilder = remember { + NetworkHeaders.Builder() + .set("Referer", "https://${debouncedImageUrl.toUri().host}") + .build() + } + val model = remember { ImageRequest.Builder(context) + .data(debouncedImageUrl) + .httpHeaders(headersBuilder) + .crossfade(true) + .build() + } + AsyncImage( + model = model, + contentDescription = null, + onSuccess = { isUrlPreviewLoaded = true }, + onError = { isUrlPreviewLoaded = false } + ) + } + + fun onSearch() { + if (prefs.saucenaoApiKey.isEmpty()) { + showToast(context, "Set a SauceNAO API key in General Settings") + return + } + + if (imageUrl.isNotEmpty()) { + navController.navigate(SauceNaoResults(imageUrl = imageUrl)) + } else if (selectedFileUri != null) { + navController.navigate(SauceNaoResults(fileUri = selectedFileUri.toString())) + } else { + showToast(context, "Enter a URL or select an image") + } + } + + MainScreenScaffold( + title = "SauceNAO" + ) { padding -> + Column( + modifier = Modifier + .padding(padding) + .padding(horizontal = SMALL_LARGE_SPACER.dp) + .fillMaxWidth() + ) { + // URL input field + TextField( + value = imageUrl, + onValueChange = { + imageUrl = it + if (it.isNotEmpty()) selectedFileUri = null + }, + modifier = Modifier.fillMaxWidth(), + textStyle = MaterialTheme.typography.searchField, + placeholder = { + Text( + text = "Enter an image URL", + style = MaterialTheme.typography.searchField + ) + }, + singleLine = true, + shape = CircleShape, + colors = TextFieldDefaults.colors().copy( + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + focusedContainerColor = MaterialTheme.colorScheme.surfaceBright, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceBright + ), + prefix = { Spacer(Modifier.width(TINY_SPACER.dp)) }, + trailingIcon = { + Row(Modifier.padding(end = SMALL_SPACER.dp)) { + if (imageUrl.isNotEmpty()) { + IconButton( + onClick = { imageUrl = "" } + ) { + Icon( + imageVector = Icons.Rounded.Clear, + contentDescription = "Clear URL" + ) + } + } else { + IconButton( + onClick = { + scope.launch { + val clipboardText = + clipboard.nativeClipboard.primaryClip?.getItemAt( + 0 + )?.text?.toString() + if (clipboardText != null) { + imageUrl = clipboardText + selectedFileUri = null + } else { + showToast(context, "Nothing on the clipboard") + } + } + } + ) { + Icon( + imageVector = Icons.Rounded.ContentPaste, + contentDescription = "Paste from clipboard" + ) + } + } + } + }, + keyboardOptions = KeyboardOptions( + imeAction = ImeAction.Search + ), + keyboardActions = KeyboardActions( + onSearch = { onSearch() } + ) + ) + + SmallVerticalSpacer() + + Surface( + shape = largerShape, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + color = MaterialTheme.colorScheme.surfaceContainer + ) { + Column(Modifier.padding(SMALL_LARGE_SPACER.dp)) { + Text( + text = "or choose from your device", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.fillMaxWidth(), + textAlign = TextAlign.Center + ) + + SmallVerticalSpacer() + + OutlinedButton( + onClick = { imagePickerLauncher.launch("image/*") }, + modifier = Modifier.fillMaxWidth(), + shapes = ButtonDefaults.shapes() + ) { + Icon( + imageVector = Icons.Rounded.Image, + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(Modifier.width(SMALL_SPACER.dp)) + Text("Select image") + } + + Spacer(Modifier.height(TINY_SPACER.dp)) + + Button( + onClick = { onSearch() }, + modifier = Modifier.fillMaxWidth(), + shapes = ButtonDefaults.shapes() + ) { + Icon( + imageVector = Icons.Rounded.Search, + contentDescription = "Search", + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(Modifier.width(ButtonDefaults.IconSpacing)) + Text("Search") + } + } + } + + // Thumbnail preview of selected file or URL + val showPreview = selectedFileUri != null || (isUrlValid && isUrlPreviewLoaded) + val currentPreviewModel = selectedFileUri ?: (if (isUrlValid && isUrlPreviewLoaded) debouncedImageUrl else null) + + // Store the last valid preview state so it doesn't disappear before the exit animation + var lastPreviewModel by remember { mutableStateOf(null) } + var lastIsFile by remember { mutableStateOf(false) } + + if (currentPreviewModel != null) { + lastPreviewModel = currentPreviewModel + lastIsFile = selectedFileUri != null + } + + AnimatedVisibility( + visible = showPreview, + enter = fadeIn(), + exit = fadeOut() + ) { + lastPreviewModel?.let { model -> + BasicExpressiveContainer( + position = ListItemPosition.SINGLE_ELEMENT, + modifier = Modifier.padding(top = SMALL_SPACER.dp) + ) { + FeaturedImageTitleSummary( + featuredImage = { + AsyncImage( + model = model, + contentDescription = if (lastIsFile) "Selected image" else "Image URL preview", + contentScale = ContentScale.Crop, + modifier = Modifier.clip(MaterialTheme.shapes.medium) + ) + }, + summary = "This image will be sent to SauceNAO.\n" + + "Refer to SauceNAO's privacy policy to understand how your " + + "data is used.", + trailingIcon = if (lastIsFile) { + { + IconButton( + onClick = { selectedFileUri = null } + ) { + Icon( + imageVector = Icons.Rounded.Clear, + contentDescription = "Remove image", + tint = MaterialTheme.colorScheme.primary + ) + } + } + } else null + ) + } + } + } + } + } +} diff --git a/app/src/main/java/moe/apex/breadboard/saucenao/SauceNao.kt b/app/src/main/java/moe/apex/breadboard/saucenao/SauceNao.kt new file mode 100644 index 00000000..6e8f83bc --- /dev/null +++ b/app/src/main/java/moe/apex/breadboard/saucenao/SauceNao.kt @@ -0,0 +1,259 @@ +package moe.apex.breadboard.saucenao + +import moe.apex.breadboard.RequestUtil +import moe.apex.breadboard.social.SocialSite +import moe.apex.breadboard.navigation.ImageView +import moe.apex.breadboard.preferences.ImageSource +import moe.apex.breadboard.util.PixivArtwork +import moe.apex.breadboard.util.isWebLink +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.MultipartBody +import okhttp3.RequestBody.Companion.toRequestBody +import okio.IOException +import org.json.JSONArray +import org.json.JSONObject +import java.net.URLEncoder +import kotlin.let + + +data class SauceNaoResponse( + val header: SauceNaoResponseHeader, + val results: List +) + + +data class SauceNaoResponseHeader( + val status: Int, + val message: String?, + val resultsCount: Int +) + + +data class SauceNaoResult( + val header: SauceNaoResultHeader, + val data: SauceNaoResultData +) + + +data class SauceNaoResultHeader( + val similarity: Float, + val thumbnail: String, + // val indexId: Int, + // val indexName: String, +) + + +data class SauceNaoResultData( + val extUrls: Set, + val title: String?, + val authorUrl: String?, + val source: String?, + val danbooruId: Int?, + val gelbooruId: Int?, + val yandereId: Int?, + val pixivId: Int?, + val artistName: String?, +) { + /** Attempts to take the URLs provided by SauceNAO and turn them into useful objects. */ + fun mapUrls(): Map> { + val socials = mutableMapOf>() + + // SauceNao::parseResponse also injects the source into extUrls if the source is a URL. + for (url in extUrls) { + SocialSite.fromUrl(url).let { + if (it != SocialSite.IMAGEBOARD) { + socials[it] = (socials[it] ?: emptyList()) + url + } + } + } + return socials + } + + + fun parseImageBoards(): List { + val boards = mutableListOf() + + danbooruId?.let { + boards.add(ImageView(ImageSource.DANBOORU, it.toString(), isMd5 = false)) + } + gelbooruId?.let { + boards.add(ImageView(ImageSource.GELBOORU, it.toString(), isMd5 = false)) + } + yandereId?.let { + boards.add(ImageView(ImageSource.YANDERE, it.toString(), isMd5 = false)) + } + + return boards + } +} + + +/* These are currently unused but might be useful in future. +// SauceNAO database index IDs for sites we support +fun getImageSourceForIndex(indexId: Int): ImageSource? { + return when (indexId) { + 9 -> ImageSource.DANBOORU + 12 -> ImageSource.YANDERE + 25 -> ImageSource.GELBOORU + // 26 is konachan which we don't currently support but could in the future. + else -> null + } +} + + +fun getSocialSiteForIndex(indexId: Int): SocialSite? { + return when (indexId) { + 5, 6 -> SocialSite.PIXIV + 35 -> SocialSite.PAWOO + 41 -> SocialSite.TWITTER + 44 -> SocialSite.SKEB + else -> null + } +} +*/ + + +object SauceNao { + private const val API_URL = "https://saucenao.com/search.php" + + suspend fun search(imageUrl: String, apiKey: String, numResults: Int = 10, allowNsfw: Boolean): SauceNaoResponse { + val encodedUrl = URLEncoder.encode(imageUrl, "utf-8") + val encodedKey = URLEncoder.encode(apiKey, "utf-8") + val requestUrl = "$API_URL?output_type=2&hide=${if (allowNsfw) 0 else 3}&numres=$numResults&url=$encodedUrl&api_key=$encodedKey" + + val responseBody = try { + RequestUtil.get(requestUrl) + } catch (e: IOException) { + val errorText = if (e.message?.toInt() == 429) { + "SauceNAO is limiting you. Try again later." + } else { + "Unknown SauceNAO error." + } + throw SauceNaoException(errorText, e.message?.toInt()) + } + return parseResponse(responseBody, allowNsfw) + } + + suspend fun searchWithFile( + fileBytes: ByteArray, + fileName: String, + mimeType: String, + apiKey: String, + numResults: Int = 10, + allowNsfw: Boolean + ): SauceNaoResponse { + val body = MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart("output_type", "2") + .addFormDataPart("hide", if (allowNsfw) "0" else "3") + .addFormDataPart("numres", numResults.toString()) + .addFormDataPart("api_key", apiKey) + .addFormDataPart( + "file", + fileName, + fileBytes.toRequestBody(mimeType.toMediaType()) + ) + .build() + + val responseBody = try { + RequestUtil.post(API_URL, body) + } catch (e: IOException) { + val errorText = if (e.message?.toInt() == 429) { + "SauceNAO is limiting you. Try again later." + } else { + "Unknown SauceNAO error." + } + throw SauceNaoException(errorText, e.message?.toInt()) + } + return parseResponse(responseBody, allowNsfw) + } + + private fun parseResponse(body: String, allowNsfw: Boolean): SauceNaoResponse { + val json = JSONObject(body) + val headerJson = json.getJSONObject("header") + + val header = SauceNaoResponseHeader( + status = headerJson.getInt("status"), + message = headerJson.optString("message", null), + resultsCount = headerJson.optInt("results_returned", -1) + ) + + if (header.status != 0) { + val errorMessage = header.message ?: "SauceNAO returned status ${header.status}" + throw SauceNaoException(errorMessage, header.status) + } + + val resultsJson = json.optJSONArray("results") ?: return SauceNaoResponse(header, emptyList()) + val results = mutableListOf() + + for (i in 0 until resultsJson.length()) { + val resultJson = resultsJson.getJSONObject(i) + + val resultHeaderJson = resultJson.getJSONObject("header") + + if (resultHeaderJson.optInt("hidden", 0) != 0 && !allowNsfw) { + continue + } + + val resultHeader = SauceNaoResultHeader( + similarity = resultHeaderJson.optString("similarity", "0").toFloatOrNull() ?: 0f, + thumbnail = resultHeaderJson.optString("thumbnail", ""), + // Not currently using these but maybe in future + // indexId = resultHeaderJson.optInt("index_id", -1), + // indexName = resultHeaderJson.optString("index_name", ""), + ) + + val resultDataJson = resultJson.getJSONObject("data") + + /* Sometimes the source is a URL that isn't listed in ext_urls. + We'll add it ourselves for convenience. + If the source is also a (potentially direct image) Pixiv URL, + we'll try to normalise it before adding it.*/ + val source = resultDataJson.optString("source").takeIf { it.isNotBlank() } + val extUrls = mutableSetOf() + source?.let { + if (it.isWebLink()) { + extUrls.add(PixivArtwork.fromUrl(it)?.toString() ?: it ) + } + } + resultDataJson.optJSONArray("ext_urls")?.let { arr -> + for (index in 0 until arr.length()) { + val url = arr.getString(index) + // It might return them here too. Same again. + extUrls.add(PixivArtwork.fromUrl(url)?.toString() ?: url) + } + } + + val resultData = SauceNaoResultData( + extUrls = extUrls, + title = resultDataJson.optString("title").takeIf { it.isNotBlank() }, + authorUrl = resultDataJson.optString("author_url").takeIf { it.isNotBlank() }, + source = source, + danbooruId = resultDataJson.optInt("danbooru_id", 0).takeIf { it != 0 }, + gelbooruId = resultDataJson.optInt("gelbooru_id", 0).takeIf { it != 0 }, + yandereId = resultDataJson.optInt("yandere_id", 0).takeIf { it != 0 }, + pixivId = resultDataJson.optInt("pixiv_id", 0).takeIf { it != 0 }, + artistName = resultDataJson.optString("member_name", + resultDataJson.optString("author_name", + resultDataJson.optString("creator", + resultDataJson.optString("twitter_user_handle")) + ) + ).takeIf { it.isNotBlank() } + ?.let { // Sometimes it's a list and sometimes it's a string. Very cool. + if (it.startsWith("[\"")) { + JSONArray(it).getString(0) + } else { + it + } + } ?: continue, // TODO: No artist name means it's probably from a show or media. We'll skip them for now, but should revisit. Index ID would confirm. + ) + + results.add(SauceNaoResult(resultHeader, resultData)) + } + + return SauceNaoResponse(header, results) + } +} + + +class SauceNaoException(message: String, val statusCode: Int?) : Exception(message) diff --git a/app/src/main/java/moe/apex/breadboard/saucenao/SauceNaoResultsScreen.kt b/app/src/main/java/moe/apex/breadboard/saucenao/SauceNaoResultsScreen.kt new file mode 100644 index 00000000..57e5933a --- /dev/null +++ b/app/src/main/java/moe/apex/breadboard/saucenao/SauceNaoResultsScreen.kt @@ -0,0 +1,570 @@ +package moe.apex.breadboard.saucenao + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.ExpandMore +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.net.toUri +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import coil3.compose.AsyncImage +import moe.apex.breadboard.R +import moe.apex.breadboard.social.SocialSite +import moe.apex.breadboard.social.sortSocialSites +import moe.apex.breadboard.image.ImageBoardRequirement +import moe.apex.breadboard.largeimageview.OffsetBasedLargeImageView +import moe.apex.breadboard.navigation.ImageView +import moe.apex.breadboard.preferences.Experiment +import moe.apex.breadboard.preferences.LocalPreferences +import moe.apex.breadboard.util.BasicExpressiveContainer +import moe.apex.breadboard.util.CHIP_SPACING +import moe.apex.breadboard.util.LARGE_SPACER +import moe.apex.breadboard.util.LargeTitleBar +import moe.apex.breadboard.util.ListItemPosition +import moe.apex.breadboard.util.MEDIUM_SPACER +import moe.apex.breadboard.util.MainScreenScaffold +import moe.apex.breadboard.util.SMALL_LARGE_SPACER +import moe.apex.breadboard.util.SMALL_SPACER +import moe.apex.breadboard.util.TINY_SPACER +import moe.apex.breadboard.util.WideLinearWavyProgressIndicator +import moe.apex.breadboard.util.bouncyAnimationSpec +import moe.apex.breadboard.util.filterChipSolidColor +import moe.apex.breadboard.util.largerShapeCornerSize +import moe.apex.breadboard.util.navBarHeight +import moe.apex.breadboard.util.openUrl +import moe.apex.breadboard.util.showToast +import moe.apex.breadboard.viewmodel.ResultsState +import moe.apex.breadboard.viewmodel.SaucenaoResultsViewModel + + +private val MATCH_BOX_HEIGHT = 24.dp +private val TITLE_HEIGHT = LARGE_SPACER.dp + + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class, ExperimentalLayoutApi::class) +@Composable +fun SauceNaoResultsScreen( + navController: NavController, + imageUrl: String, + fileUri: String = "" +) { + val context = LocalContext.current + val prefs = LocalPreferences.current + val topAppBarState = rememberTopAppBarState() + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(topAppBarState) + val blur = prefs.isExperimentEnabled(Experiment.IMMERSIVE_UI_EFFECTS) + + val viewModel: SaucenaoResultsViewModel = viewModel() + val viewableImages by viewModel.viewableImages.collectAsState() + var selectedImages by remember { mutableStateOf(viewableImages) } + val state by viewModel.resultsState.collectAsState() + + // For inline image viewing via OffsetBasedLargeImageView + var isImageViewerActive by remember { mutableStateOf(false) } + + fun onViewInBreadboard(vararg ids: Int?) { + val ids = ids.filterNotNull() + selectedImages = viewableImages.filter { it.id?.toInt() in ids } + + if (selectedImages.all { + it.imageSource.imageBoard.apiKeyRequirement == ImageBoardRequirement.REQUIRED && + prefs.authFor(it.imageSource, context) == null + } ) { + showToast(context, "Unable to open due to missing API keys.") + } else { + isImageViewerActive = true + } + } + + suspend fun performSearch() { + if (fileUri.isNotEmpty()) { + val uri = fileUri.toUri() + val contentResolver = context.contentResolver + val mimeType = contentResolver.getType(uri) ?: "application/octet-stream" + val fileBytes = contentResolver.openInputStream(uri)?.use { it.readBytes() } + ?: throw IllegalArgumentException("Could not open file") + val fileName = uri.lastPathSegment ?: "image.jpg" + viewModel.performFileSearch( + fileBytes = fileBytes, + fileName = fileName, + mimeType = mimeType, + apiKey = prefs.saucenaoApiKey, + allowNsfw = prefs.saucenaoAllowNsfw, + authFactory = { + prefs.authFor(it, context) + } + ) + } else { + viewModel.performSearch( + imageUrl = imageUrl, + apiKey = prefs.saucenaoApiKey, + allowNsfw = prefs.saucenaoAllowNsfw, + authFactory = { + prefs.authFor(it, context) + } + ) + } + } + + LaunchedEffect(imageUrl, fileUri) { + if (state !is ResultsState.Success) { + performSearch() + } + } + + MainScreenScaffold( + topAppBar = { + LargeTitleBar( + title = "SauceNAO", + scrollBehavior = scrollBehavior, + navController = navController + ) + }, + addBottomPadding = false, + blur = isImageViewerActive && blur, + ) { padding -> + AnimatedContent( + targetState = state, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + modifier = Modifier + .fillMaxSize() + .padding(padding) + ) { currentState -> + when (currentState) { + is ResultsState.Loading -> { + Box(modifier = Modifier + .padding(SMALL_LARGE_SPACER.dp)) { + WideLinearWavyProgressIndicator( + modifier = Modifier + .fillMaxWidth() + ) + } + } + + is ResultsState.Error -> { + showToast(context, "${currentState.message} ${currentState.statusCode}") + navController.popBackStack() + } + + is ResultsState.Success -> { + if (currentState.groups.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = "No results found :(", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else { + var expandedGroupIndex by remember { mutableIntStateOf(-1) } + LazyColumn( + modifier = Modifier + .fillMaxSize() + .nestedScroll(scrollBehavior.nestedScrollConnection), + contentPadding = PaddingValues( + top = MEDIUM_SPACER.dp, + start = MEDIUM_SPACER.dp, + end = MEDIUM_SPACER.dp, + bottom = navBarHeight + MEDIUM_SPACER.dp + ), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + currentState.groups.forEachIndexed { groupIndex, group -> + // Primary result with merged links + val isExpanded = expandedGroupIndex == groupIndex + item(key = groupIndex.toString()) { + BasicExpressiveContainer( + modifier = Modifier + .animateItem(placementSpec = bouncyAnimationSpec()) + .padding( + bottom = if (expandedGroupIndex == groupIndex + 1) { + LARGE_SPACER - 2 + } else { + 0 + }.dp + ), + // I *think* this covers all bases but I probably forgot something lol + position = when { + isExpanded -> ListItemPosition.TOP // If this result is expanded + currentState.header.resultsCount == 1 -> ListItemPosition.SINGLE_ELEMENT // If this is the only result + groupIndex == 0 && expandedGroupIndex == 1 -> ListItemPosition.SINGLE_ELEMENT // If this is the first result and the result below this is expanded + groupIndex == 0 -> ListItemPosition.TOP // If this is the first result and the result below this is not expanded + groupIndex == currentState.groups.lastIndex && expandedGroupIndex == groupIndex - 1 -> ListItemPosition.SINGLE_ELEMENT // If this is the last result and the one above this is expanded + groupIndex - 1 == expandedGroupIndex -> ListItemPosition.TOP // If this is not the last result, but the result above this is expanded + expandedGroupIndex == groupIndex + 1 -> ListItemPosition.BOTTOM // If this is not the first result and the below this is expanded. + groupIndex == currentState.groups.lastIndex -> ListItemPosition.BOTTOM // If this is the last result and the result above is not expanded. + else -> ListItemPosition.MIDDLE // Everything else + } + ) { + SauceNaoResultItem( + result = group.primaryResult, + isBestMatch = groupIndex == 0 && group.primaryResult.header.similarity >= 80f, + urls = group.mergedUrls, + imageBoards = group.mergedImageBoards, + onOpenUrl = { url -> openUrl(context, url) }, + onViewInBreadboard = { + onViewInBreadboard( + group.primaryResult.data.danbooruId, + group.primaryResult.data.gelbooruId, + group.primaryResult.data.yandereId + ) + }, + relatedCount = group.relatedResults.size, + isExpanded = isExpanded, + onToggleExpand = { + expandedGroupIndex = if (isExpanded) { + -1 + } else { + groupIndex + } + } + ) + } + } + + if (isExpanded) { + // Related results (shown when expanded) + itemsIndexed( + items = group.relatedResults, + key = { index, _ -> "$groupIndex-$index" } + ) { index, related -> + val isLastRelated = index == group.relatedResults.lastIndex + BasicExpressiveContainer( + modifier = Modifier + .animateItem() + .padding( + bottom = if (isLastRelated) { + LARGE_SPACER - 2 + } else { + 0 + }.dp + ), + position = when { + isLastRelated -> ListItemPosition.BOTTOM + else -> ListItemPosition.MIDDLE + } + ) { + SauceNaoResultItem( + result = related, + isBestMatch = false, + onOpenUrl = { url -> + openUrl( + context, + url + ) + }, + onViewInBreadboard = { + onViewInBreadboard( + group.primaryResult.data.danbooruId, + group.primaryResult.data.gelbooruId, + group.primaryResult.data.yandereId + ) + } + ) + } + } + } + } + + // Total displayed results across all groups + val totalResults = + currentState.groups.sumOf { 1 + it.relatedResults.size } + if (currentState.header.resultsCount != totalResults) { + item { + Text( + text = "Some potentially explicit results have been hidden.\n" + + "Adjust this in Content settings.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(MEDIUM_SPACER.dp) + ) + } + } + } + } + } + } + } + } + + /* We're not setting onImageUpdate here because the viewmodel's performSearch handles fetching + the tag groups already (and those are the primary use case for onImageUpdate). */ + OffsetBasedLargeImageView( + navController = navController, + isActive = isImageViewerActive, + initialSelectedImageIndex = 0, + allImages = selectedImages, + onActiveStateChanged = { isImageViewerActive = it } + ) +} + + +/** + * A single SauceNAO result item. + * + * When used as a primary result in a group, pass [urls] and [imageBoards] for the merged data, + * [relatedCount] for the number of related results, [isExpanded] and [onToggleExpand] for the + * expand/collapse chevron. + * + * When used as a related result, omit the optional parameters to use the result's own data. + */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun SauceNaoResultItem( + modifier: Modifier = Modifier, + result: SauceNaoResult, + isBestMatch: Boolean, + onOpenUrl: (String) -> Unit, + onViewInBreadboard: () -> Unit, + urls: Map> = result.data.mapUrls(), + imageBoards: List = result.data.parseImageBoards(), + relatedCount: Int = 0, + isExpanded: Boolean = false, + onToggleExpand: (() -> Unit)? = null +) { + Column(modifier = modifier) { + Box { + Row( + modifier = modifier + .fillMaxWidth() + .padding( + start = SMALL_LARGE_SPACER.dp, + end = SMALL_LARGE_SPACER.dp, + top = SMALL_LARGE_SPACER.dp + // Bottom will be handled by either the URL chips or the image, whichever has the lowest point in the UI. + ), + horizontalArrangement = Arrangement.spacedBy(SMALL_LARGE_SPACER.dp) + ) { + /* TODO: Make thumbnail tappable? + Will need adjustments to the image viewer to support non-Image items. */ + if (result.header.thumbnail.isNotEmpty()) { + AsyncImage( + model = result.header.thumbnail, + contentDescription = "Result thumbnail", + contentScale = ContentScale.Crop, + modifier = Modifier + /* A bit of manual work to ensure the image lines up with the text and + exactly one row of ext url chips. + A simpler way would be fillMaxHeight and IntrinsicSize.Min on the + parent Row, but that then introduces complications if we have more + than one row of url chips, so we'll just keep this. */ + .padding(bottom = SMALL_LARGE_SPACER.dp) + .size(MATCH_BOX_HEIGHT + MEDIUM_SPACER.dp + TITLE_HEIGHT + SMALL_SPACER.dp + 8.dp + FilterChipDefaults.Height) + .clip(RoundedCornerShape(largerShapeCornerSize)) + ) + } + + Column(Modifier.weight(1f)) { + val similarity = result.header.similarity + val badgeColour = when { + similarity >= 80f -> Color(0xFF43A047) // Green 600 + similarity >= 50f -> Color(0xFFF57C00) // Orange 700 + else -> Color(0xFFE53935) // Red 600 + } + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .background( + shape = CircleShape, + color = badgeColour.copy(alpha = 0.2f) + ) + .height(MATCH_BOX_HEIGHT) + .padding(horizontal = SMALL_SPACER.dp) + ) { + Text( + text = "$similarity% " + if (isBestMatch) "⸱ Best match" else "match", + style = MaterialTheme.typography.labelSmall, + fontSize = 12.sp + ) + } + + /* We're doing this to enforce a specific layout size for the text in order to + keep everything aligned. + The height of the text itself is allowed to overflow outside of this Box + to prevent clipping caused by CJK characters screwing with the expected + line height. + + Will this cause issues if someone has a silly font size/dpi? + I'm sure we'll find out eventually. */ + Box( + modifier = Modifier + .padding(top = MEDIUM_SPACER.dp, bottom = SMALL_SPACER.dp) // The site chips have their own padding already + .height(TITLE_HEIGHT), + contentAlignment = Alignment.CenterStart + ) { + Text( + text = buildAnnotatedString { + append("Art by ") + withStyle( + style = SpanStyle(color = MaterialTheme.colorScheme.primary) + ) { + append(result.data.artistName) + } + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Start, + fontSize = 20.sp, + fontWeight = FontWeight.Bold, + fontFamily = FontFamily(Font(R.font.kumbh)), + modifier = Modifier.wrapContentHeight(unbounded = true) + ) + } + + val sortedSocials = sortSocialSites(urls.keys) + + if (sortedSocials.isNotEmpty()) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(CHIP_SPACING.dp), + // Chips 8dp vertical yada yada you've heard it before + modifier = Modifier.padding(bottom = (SMALL_LARGE_SPACER - 8).dp) + ) { + for (site in sortedSocials) { + val siteUrls = urls[site]!! + for (url in siteUrls) { + FilterChip( + selected = true, + onClick = { onOpenUrl(url) }, + label = { + Text( + text = if (site != SocialSite.OTHER) { + site.label + } else { + url.toUri().host.toString() + .removePrefix("www.") + } + ) + }, + border = null, + ) + } + } + } + } + } + } + + // We're putting this separately in the Box so it doesn't affect the layout of the rest. + if (relatedCount > 0 && onToggleExpand != null) { + val rotation by animateFloatAsState( + targetValue = if (isExpanded) 180f else 0f, + label = "chevron_rotation" + ) + FilterChip( + modifier = Modifier + .align(Alignment.TopEnd) + .padding( + top = TINY_SPACER.dp, // They have 8dp vertical already + end = MEDIUM_SPACER.dp + ), + selected = isExpanded, + onClick = onToggleExpand, + shapes = FilterChipDefaults.shapes().copy( + shape = CircleShape, + selectedShape = FilterChipDefaults.shape + ), + colors = filterChipSolidColor.copy( + containerColor = filterChipSolidColor.selectedContainerColor, + labelColor = filterChipSolidColor.selectedLabelColor, + leadingIconColor = filterChipSolidColor.selectedLeadingIconColor + ), + border = null, + label = { Text("+$relatedCount") }, + leadingIcon = { + Icon( + imageVector = Icons.Rounded.ExpandMore, + contentDescription = if (isExpanded) { + "Collapse $relatedCount related results" + } else { + "Expand $relatedCount related results" + }, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.graphicsLayer { + rotationZ = rotation + } + ) + } + ) + } + } + if (imageBoards.isNotEmpty()) { + Button( + onClick = onViewInBreadboard, + shapes = ButtonDefaults.shapes(), + modifier = Modifier + .fillMaxWidth() + .padding( + start = SMALL_LARGE_SPACER.dp, + end = SMALL_LARGE_SPACER.dp, + bottom = SMALL_LARGE_SPACER.dp + ) + ) { + Text("Open in Breadboard") + } + } + } +} diff --git a/app/src/main/java/moe/apex/breadboard/artist/ArtistSocial.kt b/app/src/main/java/moe/apex/breadboard/social/SocialEntry.kt similarity index 76% rename from app/src/main/java/moe/apex/breadboard/artist/ArtistSocial.kt rename to app/src/main/java/moe/apex/breadboard/social/SocialEntry.kt index 7c77cd6e..f5688930 100644 --- a/app/src/main/java/moe/apex/breadboard/artist/ArtistSocial.kt +++ b/app/src/main/java/moe/apex/breadboard/social/SocialEntry.kt @@ -1,4 +1,4 @@ -package moe.apex.breadboard.artist +package moe.apex.breadboard.social import androidx.compose.runtime.Immutable import moe.apex.breadboard.R @@ -6,7 +6,7 @@ import androidx.core.net.toUri @Immutable -data class ArtistSocial( +data class SocialEntry( val url: String, val isActive: Boolean ) @@ -35,6 +35,7 @@ enum class SocialSite( TWITTER("Twitter", 0xFF1DA1F2, R.drawable.ic_twitter, listOf("twitter.com", "x.com")), WEIBO("Weibo", 0xFFE6162D, R.drawable.ic_weibo, listOf("weibo.com", "weibo.cn")), YOUTUBE("YouTube", 0xFFFF1A47, R.drawable.ic_youtube, listOf("youtube.com", "youtu.be")), + IMAGEBOARD("Imageboard", null, R.drawable.ic_link, listOf("safebooru.org", "danbooru.donmai.us", "safebooru.donmai.us", "gelbooru.com", "yande.re")), // Only intended to be used by saucenao so we can filter them out. OTHER("Other links", null, R.drawable.ic_link, emptyList()); companion object { @@ -61,3 +62,17 @@ enum class SocialSite( } } } + + +fun sortSocialSites(socialSites: Set): List { + return socialSites + .sortedWith(compareBy { + when (it) { // Site priority, always show Pixiv/Fanbox/Twitter first and OTHER last. + SocialSite.PIXIV -> 0 + SocialSite.FANBOX -> 1 + SocialSite.TWITTER -> 2 + SocialSite.OTHER -> 4 + else -> 3 + } + }.thenBy { it.label.lowercase() }) // And then every other site is alphabetical. +} 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 5f4aaba3..1655a60c 100644 --- a/app/src/main/java/moe/apex/breadboard/util/Pixiv.kt +++ b/app/src/main/java/moe/apex/breadboard/util/Pixiv.kt @@ -1,11 +1,18 @@ package moe.apex.breadboard.util +// Regular public post URLs +private val PIXIV_POST_RX = + // https://www.pixiv.net/artworks/128105617 + // https://www.pixiv.net/en/artworks/128105617 + // https://www.pixiv.net/member_illust.php?mode=medium&illust_id=128105617 + """https?://(?:www\.)?pixiv\.net/(?:(?:[a-z]{2}/)?artworks/|member_illust\.php\?.*?illust_id=)(?\d+)""".toRegex() + // Current pixiv direct image URLs 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/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() + """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( @@ -33,7 +40,9 @@ data class PixivArtwork(val id: Int, val index: Int) { It feels better and more intentional/deliberate to handle them specifically, at least compared to putting a try/catch around the index group. */ - val indexedRegexes = listOf(PIXIV_CURRENT_RX) + PIXIV_2012_TO_2016_RX + val indexedRegexes = PIXIV_2012_TO_2016_RX + PIXIV_CURRENT_RX + val nonIndexedRegexes = listOf(PIXIV_POST_RX, PIXIV_PRE_2012_RX) + for (regex in indexedRegexes) { val match = regex.find(url) ?: continue val id = match.groups["id"]?.value?.toIntOrNull().takeIf { it != 0 } ?: continue @@ -41,15 +50,18 @@ data class PixivArtwork(val id: Int, val index: Int) { return PixivArtwork(id, index) } - val pre2012Match = PIXIV_PRE_2012_RX.find(url) - if (pre2012Match != null) { - val id = pre2012Match.groups["id"]?.value?.toIntOrNull().takeIf { it != 0 } - if (id != null) { - return PixivArtwork(id, 0) - } + for (regex in nonIndexedRegexes) { + val match = regex.find(url) ?: continue + val id = match.groups["id"]?.value?.toIntOrNull().takeIf { it != 0 } ?: continue + return PixivArtwork(id, 0) } return null } } + + /** Return the artwork as a regular Pixiv link. */ + override fun toString(): String { + return "https://www.pixiv.net/artworks/$id" + if (index != 0) "#$index" else "" + } } 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 cdef2a1f..83570520 100644 --- a/app/src/main/java/moe/apex/breadboard/util/Ui.kt +++ b/app/src/main/java/moe/apex/breadboard/util/Ui.kt @@ -448,6 +448,57 @@ fun TitleSummary( trailingIcon: @Composable (() -> Unit)? = null, onClick: (() -> Unit)? = null ) { + TitleSummary( + modifier = modifier, + title = title, + summary = summary, + enabled = enabled, + leadingIcon = leadingIcon, + leadingIconSize = 42.dp, + trailingIcon = trailingIcon, + onClick = onClick + ) +} + + +@Composable +fun FeaturedImageTitleSummary( + modifier: Modifier = Modifier, + title: String? = null, + summary: String? = null, + enabled: Boolean = true, + featuredImage: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + onClick: (() -> Unit)? = null +) { + TitleSummary( + modifier = modifier, + title = title, + summary = summary, + enabled = enabled, + leadingIcon = featuredImage, + leadingIconSize = 80.dp, + trailingIcon = trailingIcon, + onClick = onClick + ) +} + + +@Composable +private fun TitleSummary( + modifier: Modifier = Modifier, + title: String? = null, + summary: String? = null, + enabled: Boolean = true, + leadingIcon: @Composable (() -> Unit)? = null, + leadingIconSize: Dp, + trailingIcon: @Composable (() -> Unit)? = null, + onClick: (() -> Unit)? = null +) { + if (title == null && summary == null) { + throw IllegalArgumentException("Provide at least one of `title` or `summary`.") + } + val baseModifier = modifier.heightIn(min = 72.dp) val finalModifier = onClick?.let { baseModifier.clickable(enabled) { it() } } ?: baseModifier @@ -460,7 +511,7 @@ fun TitleSummary( Box( modifier = Modifier .padding(vertical = SMALL_LARGE_SPACER.dp) - .size(42.dp) + .size(leadingIconSize) .alpha(if (enabled) 1f else DISABLED_OPACITY), contentAlignment = Alignment.Center ) { @@ -475,13 +526,15 @@ fun TitleSummary( .alpha(if (enabled) 1f else DISABLED_OPACITY), verticalArrangement = Arrangement.spacedBy(2.dp) ) { - Text( - text = title, - style = MaterialTheme.typography.prefTitle, - ) + title?.let { + Text( + text = it, + style = MaterialTheme.typography.prefTitle, + ) + } - if (summary != null) { - Summary(text = summary) + summary?.let { + Summary(text = it) } } trailingIcon?.let { @@ -881,15 +934,15 @@ fun HorizontalFloatingToolbarOptionalFab( toolbarContainerColor = MaterialTheme.colorScheme.surfaceContainerHigh ), floatingActionButton: (@Composable () -> Unit)? = null, + elevation: Dp = 6.dp, // This is what spec says it's supposed to be actions: @Composable RowScope.() -> Unit ) { - val shadowElevation = 6.dp // This is what spec says but the M3 implementation does what it wants lol if (floatingActionButton != null) { HorizontalFloatingToolbar( modifier = modifier, colors = colors, expanded = true, - expandedShadowElevation = shadowElevation, + expandedShadowElevation = elevation, floatingActionButton = floatingActionButton, content = actions ) @@ -898,7 +951,7 @@ fun HorizontalFloatingToolbarOptionalFab( modifier = modifier, colors = colors, expanded = true, - expandedShadowElevation = shadowElevation, + expandedShadowElevation = elevation, content = actions ) } diff --git a/app/src/main/java/moe/apex/breadboard/viewmodel/SaucenaoResultsViewModel.kt b/app/src/main/java/moe/apex/breadboard/viewmodel/SaucenaoResultsViewModel.kt new file mode 100644 index 00000000..1537c62d --- /dev/null +++ b/app/src/main/java/moe/apex/breadboard/viewmodel/SaucenaoResultsViewModel.kt @@ -0,0 +1,106 @@ +package moe.apex.breadboard.viewmodel + +import android.util.Log +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.preferences.ImageSource +import moe.apex.breadboard.saucenao.SauceNao +import moe.apex.breadboard.saucenao.SauceNaoException +import moe.apex.breadboard.saucenao.SauceNaoResponse +import moe.apex.breadboard.saucenao.SauceNaoResponseHeader +import moe.apex.breadboard.saucenao.SauceNaoResultGroup +import moe.apex.breadboard.saucenao.groupResults + + +sealed class ResultsState { + data object Loading : ResultsState() + data class Success(val header: SauceNaoResponseHeader, val groups: List) : ResultsState() + data class Error(val message: String, val statusCode: Int?) : ResultsState() +} + + +class SaucenaoResultsViewModel: ViewModel() { + private val _resultsState = MutableStateFlow(ResultsState.Loading) + val resultsState = _resultsState.asStateFlow() + private val _viewableImages = MutableStateFlow>(emptyList()) + val viewableImages = _viewableImages.asStateFlow() + + + suspend fun performSearch( + imageUrl: String, + apiKey: String, + allowNsfw: Boolean, + authFactory: (ImageSource) -> ImageBoardAuth? + ) { + _resultsState.value = ResultsState.Loading + try { + val response = SauceNao.search(imageUrl, apiKey, allowNsfw = allowNsfw) + processResponse(response, authFactory) + } catch (e: SauceNaoException) { + _resultsState.value = ResultsState.Error(e.message ?: "Unknown SauceNAO error", e.statusCode) + } catch (e: Exception) { + Log.e("SauceNAO", "Error searching SauceNAO", e) + _resultsState.value = ResultsState.Error(e.message ?: "An error occurred", null) + } + } + + suspend fun performFileSearch( + fileBytes: ByteArray, + fileName: String, + mimeType: String, + apiKey: String, + allowNsfw: Boolean, + authFactory: (ImageSource) -> ImageBoardAuth? + ) { + _resultsState.value = ResultsState.Loading + try { + val response = SauceNao.searchWithFile(fileBytes, fileName, mimeType, apiKey, allowNsfw = allowNsfw) + processResponse(response, authFactory) + } catch (e: SauceNaoException) { + _resultsState.value = ResultsState.Error(e.message ?: "Unknown SauceNAO error", e.statusCode) + } catch (e: Exception) { + Log.e("SauceNAO", "Error searching SauceNAO", e) + _resultsState.value = ResultsState.Error(e.message ?: "An error occurred", null) + } + } + + private suspend fun processResponse( + response: SauceNaoResponse, + authFactory: (ImageSource) -> ImageBoardAuth? + ) { + val results = response.results.sortedByDescending { it.header.similarity } + val groups = groupResults(results) + _resultsState.value = ResultsState.Success(response.header, groups) + + // Preload Image objects for all results across all groups + for (group in groups) { + val allResults = listOf(group.primaryResult) + group.relatedResults + for (result in allResults) { + val imageViews = result.data.parseImageBoards() + imageViews.forEach { + try { + val auth = authFactory(it.source) + val image = it.source.imageBoard.loadImage(it.id, auth) + if (image != null) { + if (image.hasGroupedTags) { + _viewableImages.value += image + } else { + val meta = it.source.imageBoard.loadImageGroupedTags(image, auth) + _viewableImages.value += image.copy(metadata = meta) + } + } + } catch (e: Exception) { + Log.w( + "SauceNAO", + "Failed to load image for ${it.source}:${it.id}", + e + ) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_image_search_filled.xml b/app/src/main/res/drawable/ic_image_search_filled.xml new file mode 100644 index 00000000..220d1884 --- /dev/null +++ b/app/src/main/res/drawable/ic_image_search_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_image_search_hollow.xml b/app/src/main/res/drawable/ic_image_search_hollow.xml new file mode 100644 index 00000000..ff4a3e8b --- /dev/null +++ b/app/src/main/res/drawable/ic_image_search_hollow.xml @@ -0,0 +1,9 @@ + + + From 671c0edf5cfa7f2913db08c535f16e1cb93b18b5 Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:13:43 +0100 Subject: [PATCH 19/59] Add API key settings and SauceNAO nsfw toggle --- .../breadboard/navigation/Destinations.kt | 3 + .../apex/breadboard/navigation/Navigation.kt | 3 + .../preferences/ApiKeysSettingsScreen.kt | 360 ++++++++++++++++++ .../preferences/ContentSettingsScreen.kt | 36 ++ .../preferences/GeneralSettingsScreen.kt | 124 +----- .../breadboard/preferences/Preferences.kt | 15 + .../breadboard/preferences/PreferencesUI.kt | 165 -------- 7 files changed, 428 insertions(+), 278 deletions(-) create mode 100644 app/src/main/java/moe/apex/breadboard/preferences/ApiKeysSettingsScreen.kt diff --git a/app/src/main/java/moe/apex/breadboard/navigation/Destinations.kt b/app/src/main/java/moe/apex/breadboard/navigation/Destinations.kt index 20b099c9..f28e0500 100644 --- a/app/src/main/java/moe/apex/breadboard/navigation/Destinations.kt +++ b/app/src/main/java/moe/apex/breadboard/navigation/Destinations.kt @@ -108,6 +108,9 @@ object GeneralSettings @Serializable object ContentSettings +@Serializable +object ApiKeysSettings + @Serializable object LayoutSettings diff --git a/app/src/main/java/moe/apex/breadboard/navigation/Navigation.kt b/app/src/main/java/moe/apex/breadboard/navigation/Navigation.kt index ea9da07a..d49450dc 100644 --- a/app/src/main/java/moe/apex/breadboard/navigation/Navigation.kt +++ b/app/src/main/java/moe/apex/breadboard/navigation/Navigation.kt @@ -63,6 +63,7 @@ import moe.apex.breadboard.saucenao.ReverseSearchScreen import moe.apex.breadboard.ui.theme.BreadboardTheme import moe.apex.breadboard.util.withoutVertical import moe.apex.breadboard.home.FollowedArtistsScreen +import moe.apex.breadboard.preferences.ApiKeysSettingsScreen @Composable @@ -93,6 +94,7 @@ fun Navigation(navController: NavHostController, startDestination: Any = Search) val settingsScreens = listOf( Settings::class, GeneralSettings::class, + ApiKeysSettings::class, ContentSettings::class, LayoutSettings::class, DataSettings::class, @@ -270,6 +272,7 @@ fun Navigation(navController: NavHostController, startDestination: Any = Search) composable { FavouritesPage(navController) { isNavigationBarVisible = it } } composable { PreferencesScreen(navController) } composable { GeneralSettingsScreen(navController) } + composable { ApiKeysSettingsScreen(navController) } composable { ContentSettingsScreen(navController) } composable { LayoutSettingsScreen(navController) } composable { DataSettingsScreen(navController) } diff --git a/app/src/main/java/moe/apex/breadboard/preferences/ApiKeysSettingsScreen.kt b/app/src/main/java/moe/apex/breadboard/preferences/ApiKeysSettingsScreen.kt new file mode 100644 index 00000000..933734f7 --- /dev/null +++ b/app/src/main/java/moe/apex/breadboard/preferences/ApiKeysSettingsScreen.kt @@ -0,0 +1,360 @@ +package moe.apex.breadboard.preferences + +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.MoreVert +import androidx.compose.material.icons.rounded.Star +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 +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.material3.rememberTopAppBarState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.toMutableStateMap +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withLink +import androidx.compose.ui.unit.dp +import androidx.navigation.NavHostController +import kotlinx.coroutines.launch +import moe.apex.breadboard.image.ImageBoardRequirement +import moe.apex.breadboard.prefs +import moe.apex.breadboard.util.LargeTitleBar +import moe.apex.breadboard.util.LazyExpressiveGroup +import moe.apex.breadboard.util.MainScreenScaffold +import moe.apex.breadboard.util.MEDIUM_SPACER +import moe.apex.breadboard.util.SMALL_SPACER +import moe.apex.breadboard.util.TINY_SPACER +import moe.apex.breadboard.util.TitleSummary +import moe.apex.breadboard.util.launchInWebBrowser + + +private enum class ApiKeyField(val label: String, val hide: Boolean) { + USER("User ID/name", false), + KEY("API key", true) +} + + +private enum class ApiKeyDialogType(val fields: List) { + SIMPLE(listOf(ApiKeyField.KEY)), + FULL(listOf(ApiKeyField.USER, ApiKeyField.KEY)) +} + + +private data class ApiKeyDialogData( + val type: ApiKeyDialogType, + val defaultValues: Map, + val credentialsUrl: String?, + val onDismissRequest: () -> Unit, + val onSubmit: (Map) -> Unit +) + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ApiKeysSettingsScreen(navController: NavHostController) { + val topAppBarState = rememberTopAppBarState() + val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(topAppBarState) + val scope = rememberCoroutineScope() + + var apiKeyDialogData: ApiKeyDialogData? by remember { mutableStateOf(null) } + + val preferencesRepository = LocalContext.current.prefs + val currentSettings = LocalPreferences.current + + if (apiKeyDialogData != null) { + FlexibleAuthDialog(apiKeyDialogData!!) + } + + MainScreenScaffold( + topAppBar = { + LargeTitleBar( + title = "API keys", + scrollBehavior = scrollBehavior, + navController = navController + ) + } + ) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(it) + .nestedScroll(scrollBehavior.nestedScrollConnection), + contentPadding = PaddingValues(MEDIUM_SPACER.dp) + ) { + LazyExpressiveGroup( + title = "Image sources", + desiredTopPadding = null + ) { + for (imageBoard in ImageSource.entries) { + item { + val requirement = imageBoard.imageBoard.apiKeyRequirement + val auth = currentSettings.imageBoardAuths[imageBoard] + + TitleSummary( + title = imageBoard.label, + summary = when (requirement) { + ImageBoardRequirement.NOT_NEEDED -> "${imageBoard.label} does not require an API key." + ImageBoardRequirement.RECOMMENDED -> "${imageBoard.label} provides a better experience with an API key. " + + if (auth == null) "Tap to set." else "" + ImageBoardRequirement.REQUIRED -> "${imageBoard.label} requires an API key. " + + if (auth == null) "Tap to set." else "" + }, + enabled = requirement != ImageBoardRequirement.NOT_NEEDED, + trailingIcon = if (imageBoard == currentSettings.imageSource) { + { + Icon( + imageVector = Icons.Rounded.Star, + contentDescription = "This is your current image source.", + tint = MaterialTheme.colorScheme.primary + ) + } + } else null, + onClick = { + apiKeyDialogData = ApiKeyDialogData( + type = ApiKeyDialogType.FULL, + defaultValues = mapOf( + ApiKeyField.USER to (auth?.user ?: ""), + ApiKeyField.KEY to (auth?.apiKey ?: "") + ), + credentialsUrl = imageBoard.imageBoard.apiKeyCreationUrl, + onDismissRequest = { apiKeyDialogData = null }, + onSubmit = { input -> + scope.launch { + preferencesRepository.setAuth( + source = imageBoard, + username = input[ApiKeyField.USER], + apiKey = input[ApiKeyField.KEY] + ) + }.invokeOnCompletion { + apiKeyDialogData = null + } + } + ) + } + ) + } + } + } + + LazyExpressiveGroup("SauceNAO") { + item { + TitleSummary( + modifier = Modifier.fillMaxWidth(), + title = "Set SauceNAO API key", + summary = if (currentSettings.saucenaoApiKey.isNotEmpty()) { + "API key is set." + } else { + "An API key is required to use reverse image search with SauceNAO. " + + "Tap to set." + } + ) { + apiKeyDialogData = ApiKeyDialogData( + type = ApiKeyDialogType.SIMPLE, + defaultValues = mapOf(ApiKeyField.KEY to currentSettings.saucenaoApiKey), + credentialsUrl = "https://saucenao.com/user.php?page=search-api", + onDismissRequest = { apiKeyDialogData = null }, + onSubmit = { input -> + scope.launch { + preferencesRepository.updatePref( + PreferenceKeys.SAUCENAO_API_KEY, + input[ApiKeyField.KEY] ?: "" + ) + }.invokeOnCompletion { + apiKeyDialogData = null + } + } + ) + } + } + } + } + } +} + + +@Composable +private fun FlexibleAuthDialog(data: ApiKeyDialogData) { + var inQueryParamMode by remember { mutableStateOf(false) } + val queryRegex = remember { Regex("&api_key=([^&]+)&user_id=(\\d+)") } + var queryString by remember { mutableStateOf("") } + var isQueryStringValid by remember { mutableStateOf(false) } + + val context = LocalContext.current + + val userInput = remember { data.type.fields.map { it to (data.defaultValues[it] ?: "") }.toMutableStateMap() } + + AlertDialog( + onDismissRequest = data.onDismissRequest, + title = { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text("Set API key") + + // Only allow query param entry on full type + if (data.type == ApiKeyDialogType.FULL) { + Box { + var showMenu by remember { mutableStateOf(false) } + IconButton(onClick = { showMenu = true }) { + Icon( + imageVector = Icons.Rounded.MoreVert, + contentDescription = "Options" + ) + } + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false }, + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = MaterialTheme.shapes.small + ) { + DropdownMenuItem( + text = { Text(if (inQueryParamMode) "Enter credentials normally" else "Enter query string") }, + onClick = { + inQueryParamMode = !inQueryParamMode + showMenu = false + } + ) + } + } + } + } + }, + text = { + Column { + AnimatedContent(inQueryParamMode) { + if (it) { + PreferenceTextBox( + value = queryString, + label = "Query string", + obscured = false, + keyboardType = KeyboardType.Uri, + isError = !isQueryStringValid && queryString.isNotEmpty() + ) { newValue -> + queryString = newValue.trim() + isQueryStringValid = queryRegex.containsMatchIn(queryString) + } + } else { + if (data.type == ApiKeyDialogType.SIMPLE) { + PreferenceTextBox( + value = userInput[ApiKeyField.KEY] ?: "", + label = ApiKeyField.KEY.label, + obscured = ApiKeyField.KEY.hide + ) { + userInput[ApiKeyField.KEY] = it.trim() + } + } else { + Column { + for (entry in data.type.fields) { + PreferenceTextBox( + value = userInput[entry] ?: "", + label = entry.label, + obscured = entry.hide + ) { + userInput[entry] = it.trim() + } + } + } + } + } + } + + data.credentialsUrl?.let { url -> + val apiKeyCreationText = buildAnnotatedString { + val link = LinkAnnotation.Url( + url, + TextLinkStyles( + SpanStyle( + color = MaterialTheme.colorScheme.secondary, + textDecoration = TextDecoration.Underline + ) + ) + ) { + launchInWebBrowser(context, url) + } + + withLink(link) { + append("Find your credentials...") + } + } + + Text( + text = apiKeyCreationText, + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding( + start = TINY_SPACER.dp, + top = SMALL_SPACER.dp + ) + ) + } + } + }, + confirmButton = { + Button( + enabled = if (inQueryParamMode) { + isQueryStringValid || queryString.isBlank() + } else { + userInput.values.all { it.isEmpty() } || userInput.values.none { it.isBlank() } + }, + onClick = { + if (inQueryParamMode) { + if (queryString.isBlank()) { + data.onSubmit(emptyMap()) + return@Button + } + + val match = queryRegex.find(queryString) + if (match != null) { + data.onSubmit( + mapOf( + ApiKeyField.USER to match.groupValues[2], + ApiKeyField.KEY to match.groupValues[1] + ) + ) + } + } else { + data.onSubmit(userInput) + } + } + ) { + Text("Save") + } + }, + dismissButton = { + TextButton(onClick = data.onDismissRequest) { + Text("Cancel") + } + } + ) +} diff --git a/app/src/main/java/moe/apex/breadboard/preferences/ContentSettingsScreen.kt b/app/src/main/java/moe/apex/breadboard/preferences/ContentSettingsScreen.kt index 154e89b5..e3177e98 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/ContentSettingsScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/ContentSettingsScreen.kt @@ -9,7 +9,11 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext @@ -19,6 +23,7 @@ import kotlinx.coroutines.launch import moe.apex.breadboard.navigation.BlockedTagsSettings import moe.apex.breadboard.navigation.RecommendationsSettings import moe.apex.breadboard.prefs +import moe.apex.breadboard.util.AgeVerification import moe.apex.breadboard.util.ChevronRight import moe.apex.breadboard.util.LargeTitleBar import moe.apex.breadboard.util.LazyExpressiveGroup @@ -37,6 +42,15 @@ fun ContentSettingsScreen(navController: NavHostController) { val preferencesRepository = LocalContext.current.prefs val currentSettings = LocalPreferences.current + var showAgeVerificationDialog by remember { mutableStateOf(false) } + + if (showAgeVerificationDialog) { + AgeVerification.AgeVerifyDialog( + onDismissRequest = { showAgeVerificationDialog = false }, + onAgeVerified = { showAgeVerificationDialog = false } + ) + } + MainScreenScaffold( topAppBar = { LargeTitleBar( @@ -116,6 +130,28 @@ fun ContentSettingsScreen(navController: NavHostController) { } } + LazyExpressiveGroup("SauceNAO") { + item { + SwitchPref( + checked = currentSettings.saucenaoAllowNsfw, + title = "Show all matches", + summary = "Show all results when searching SauceNAO, " + + "including ones that may contain explicit content." + ) { + if (!AgeVerification.hasVerifiedAge(currentSettings)) { + showAgeVerificationDialog = true + } else { + scope.launch { + preferencesRepository.updatePref( + PreferenceKeys.SAUCENAO_ALLOW_NSFW, + it + ) + } + } + } + } + } + LazyExpressiveGroup("Advanced") { item { SwitchPref( diff --git a/app/src/main/java/moe/apex/breadboard/preferences/GeneralSettingsScreen.kt b/app/src/main/java/moe/apex/breadboard/preferences/GeneralSettingsScreen.kt index 13310f7b..2dd748fa 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/GeneralSettingsScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/GeneralSettingsScreen.kt @@ -1,17 +1,11 @@ package moe.apex.breadboard.preferences -import androidx.compose.animation.animateContentSize -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth 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.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable @@ -23,18 +17,18 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController import kotlinx.coroutines.launch import moe.apex.breadboard.image.ImageBoardRequirement +import moe.apex.breadboard.navigation.ApiKeysSettings import moe.apex.breadboard.prefs import moe.apex.breadboard.util.AgeVerification +import moe.apex.breadboard.util.ChevronRight import moe.apex.breadboard.util.LargeTitleBar import moe.apex.breadboard.util.LazyExpressiveGroup import moe.apex.breadboard.util.MainScreenScaffold import moe.apex.breadboard.util.MEDIUM_SPACER -import moe.apex.breadboard.util.SMALL_SPACER import moe.apex.breadboard.util.TitleSummary import moe.apex.breadboard.viewmodel.getGlobalViewModel @@ -44,34 +38,13 @@ fun GeneralSettingsScreen(navController: NavHostController) { val topAppBarState = rememberTopAppBarState() val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(topAppBarState) val viewModel = getGlobalViewModel() - val context = LocalContext.current val scope = rememberCoroutineScope() - var showAuthDialog by remember { mutableStateOf(false) } var showAgeVerificationDialog by remember { mutableStateOf(false) } - var showSauceNaoKeyDialog by remember { mutableStateOf(false) } val preferencesRepository = LocalContext.current.prefs val currentSettings = LocalPreferences.current - if (showAuthDialog) { - AuthDialog( - selectedBoard = currentSettings.imageSource.imageBoard, - default = currentSettings.authFor(currentSettings.imageSource, context), - onDismissRequest = { showAuthDialog = false } - ) { username, apiKey -> - scope.launch { - preferencesRepository.setAuth( - currentSettings.imageSource, - username.takeUnless { it.isBlank() }, - apiKey.takeUnless { it.isBlank() } - ) - } - showAuthDialog = false - viewModel.resetProviders() - } - } - if (showAgeVerificationDialog) { AgeVerification.AgeVerifyDialog( onDismissRequest = { showAgeVerificationDialog = false }, @@ -79,19 +52,6 @@ fun GeneralSettingsScreen(navController: NavHostController) { ) } - if (showSauceNaoKeyDialog) { - SauceNaoApiKeyDialog( - currentKey = currentSettings.saucenaoApiKey, - onDismissRequest = { showSauceNaoKeyDialog = false }, - onSave = { key -> - scope.launch { - preferencesRepository.updatePref(PreferenceKeys.SAUCENAO_API_KEY, key) - } - showSauceNaoKeyDialog = false - } - ) - } - MainScreenScaffold( topAppBar = { LargeTitleBar( @@ -133,19 +93,17 @@ fun GeneralSettingsScreen(navController: NavHostController) { item { val authType = currentSettings.imageSource.imageBoard.apiKeyRequirement TitleSummary( - modifier = Modifier - .fillMaxWidth() - .animateContentSize(), - title = "Set API key", - summary = if (authType != ImageBoardRequirement.NOT_NEEDED) { - "${currentSettings.imageSource.label} requires an API key${if (authType == ImageBoardRequirement.RECOMMENDED) " for the best experience." else "."} " + - "Tap to set." - } else { - "${currentSettings.imageSource.label} does not require an API key." + modifier = Modifier.fillMaxWidth(), + title = "Manage API keys", + summary = if (authType == ImageBoardRequirement.NOT_NEEDED) { + "${currentSettings.imageSource.label} does not require an API key, " + + "but you can manage others here." + } else {"${currentSettings.imageSource.label} requires an API key${if (authType == ImageBoardRequirement.RECOMMENDED) " for the best experience." else "."} " + + "Set in API Key settings." }, - enabled = authType != ImageBoardRequirement.NOT_NEEDED + trailingIcon = { ChevronRight() } ) { - showAuthDialog = true + navController.navigate(ApiKeysSettings) } } item { @@ -187,66 +145,6 @@ fun GeneralSettingsScreen(navController: NavHostController) { } } } - - LazyExpressiveGroup("SauceNAO") { - item { - TitleSummary( - modifier = Modifier.fillMaxWidth(), - title = "Set SauceNAO API key", - summary = if (currentSettings.saucenaoApiKey.isNotEmpty()) { - "API key is set." - } else { - "An API key is required to use reverse image search. Tap to set." - } - ) { - showSauceNaoKeyDialog = true - } - } - } } } } - - -@Composable -private fun SauceNaoApiKeyDialog( - currentKey: String, - onDismissRequest: () -> Unit, - onSave: (String) -> Unit -) { - var apiKey by remember { mutableStateOf(currentKey) } - - AlertDialog( - onDismissRequest = onDismissRequest, - title = { Text("SauceNAO API key") }, - text = { - Column { - Text( - text = "Enter your SauceNAO API key. You can get one from saucenao.com/user.php after creating an account.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(bottom = SMALL_SPACER.dp) - ) - PreferenceTextBox( - value = apiKey, - label = "API key", - obscured = true, - keyboardType = KeyboardType.Password - ) { - apiKey = it.trim() - } - } - }, - confirmButton = { - TextButton(onClick = { onSave(apiKey) }) { - Text("Save") - } - }, - dismissButton = { - TextButton(onClick = onDismissRequest) { - Text("Cancel") - } - } - ) -} - 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 de655b56..45e5584d 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/Preferences.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/Preferences.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Dashboard import androidx.compose.material.icons.rounded.Info +import androidx.compose.material.icons.rounded.Key import androidx.compose.material.icons.rounded.MoreVert import androidx.compose.material.icons.rounded.Science import androidx.compose.material.icons.rounded.Settings @@ -40,6 +41,7 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController import moe.apex.breadboard.navigation.AboutSettings +import moe.apex.breadboard.navigation.ApiKeysSettings import moe.apex.breadboard.navigation.ContentSettings import moe.apex.breadboard.navigation.DataSettings import moe.apex.breadboard.navigation.ExperimentalSettings @@ -115,6 +117,19 @@ fun PreferencesScreen(navController: NavHostController) { navController.navigate(GeneralSettings) } } + item { + TitleSummary( + modifier = Modifier.fillMaxWidth(), + title = "API keys", + summary = "Manage your personal API keys.", + leadingIcon = { + ContainedIcon(imageVector = Icons.Rounded.Key, label = "API keys") + }, + trailingIcon = { ChevronRight() } + ) { + navController.navigate(ApiKeysSettings) + } + } item { TitleSummary( modifier = Modifier.fillMaxWidth(), 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 eba00ad7..60a47292 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/PreferencesUI.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/PreferencesUI.kt @@ -1,10 +1,8 @@ package moe.apex.breadboard.preferences -import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -27,12 +25,9 @@ import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.Clear import androidx.compose.material.icons.rounded.DragHandle import androidx.compose.material.icons.rounded.Info -import androidx.compose.material.icons.rounded.MoreVert import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Checkbox -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 @@ -63,28 +58,19 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.AnnotatedString -import androidx.compose.ui.text.LinkAnnotation -import androidx.compose.ui.text.SpanStyle -import androidx.compose.ui.text.TextLinkStyles -import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.OffsetMapping import androidx.compose.ui.text.input.TransformedText import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.text.withLink import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import moe.apex.breadboard.image.ImageBoard -import moe.apex.breadboard.image.ImageBoardAuth import moe.apex.breadboard.ui.theme.BreadboardTheme import moe.apex.breadboard.ui.theme.prefTitle import moe.apex.breadboard.ui.theme.searchField @@ -96,11 +82,9 @@ import moe.apex.breadboard.util.SMALL_LARGE_SPACER import moe.apex.breadboard.util.SMALL_SPACER import moe.apex.breadboard.util.SmallVerticalSpacer import moe.apex.breadboard.util.Summary -import moe.apex.breadboard.util.TINY_SPACER import moe.apex.breadboard.util.TitleSummary import moe.apex.breadboard.util.TitledModalBottomSheet import moe.apex.breadboard.util.VerticalSpacer -import moe.apex.breadboard.util.launchInWebBrowser import moe.apex.breadboard.util.largerShape import moe.apex.breadboard.util.navBarHeight import sh.calvin.reorderable.ReorderableItem @@ -621,155 +605,6 @@ fun > ReorderablePref( } -@Composable -fun AuthDialog( - selectedBoard: ImageBoard, - default: ImageBoardAuth?, - onDismissRequest: () -> Unit, - onSave: (String, String) -> Unit -) { - var inQueryParamMode by remember { mutableStateOf(false) } - val queryRegex = remember { Regex("&api_key=([^&]+)&user_id=(\\d+)") } - var queryString by remember { mutableStateOf("") } - var isQueryStringValid by remember { mutableStateOf(false) } - - var userId by remember { mutableStateOf(default?.user ?: "") } - var apiKey by remember { mutableStateOf(default?.apiKey ?: "") } - - val context = LocalContext.current - - AlertDialog( - onDismissRequest = onDismissRequest, - title = { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text("Set API key") - Box { - var showMenu by remember { mutableStateOf(false) } - IconButton(onClick = { showMenu = true }) { - Icon( - imageVector = Icons.Rounded.MoreVert, - contentDescription = "Options" - ) - } - DropdownMenu( - expanded = showMenu, - onDismissRequest = { showMenu = false }, - containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, - shape = MaterialTheme.shapes.small - ) { - DropdownMenuItem( - text = { Text(if (inQueryParamMode) "Enter credentials normally" else "Enter query string") }, - onClick = { - inQueryParamMode = !inQueryParamMode - showMenu = false - } - ) - } - } - } - }, - text = { - Column { - if (inQueryParamMode) { - PreferenceTextBox( - value = queryString, - label = "Query string", - obscured = false, - keyboardType = KeyboardType.Uri, - isError = !isQueryStringValid && queryString.isNotEmpty() - ) { newValue -> - queryString = newValue.trim() - isQueryStringValid = queryRegex.containsMatchIn(queryString) - } - } else { - PreferenceTextBox( - value = userId, - label = "User ID/name", - obscured = false - ) { - userId = it.trim() - } - } - - AnimatedVisibility(!inQueryParamMode) { - PreferenceTextBox( - modifier = Modifier.padding(top = SMALL_SPACER.dp), - value = apiKey, - label = "API key", - keyboardType = KeyboardType.Password, - obscured = true - ) { - apiKey = it.trim() - } - } - - selectedBoard.apiKeyCreationUrl?.let { url -> - val apiKeyCreationText = buildAnnotatedString { - val link = LinkAnnotation.Url( - url, - TextLinkStyles( - SpanStyle(color = MaterialTheme.colorScheme.secondary, textDecoration = TextDecoration.Underline) - ) - ) { - launchInWebBrowser(context, url) - } - - withLink(link) { - append("Find your credentials...") - } - } - - Text( - text = apiKeyCreationText, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.padding( - start = TINY_SPACER.dp, - top = SMALL_SPACER.dp - ) - ) - } - } - }, - confirmButton = { - Button( - enabled = if (inQueryParamMode) { - isQueryStringValid || queryString.isBlank() - } else { - (userId.isNotBlank() && apiKey.isNotBlank()) || (userId.isBlank() && apiKey.isBlank()) - }, - onClick = { - if (inQueryParamMode) { - if (queryString.isBlank()) { - onSave("", "") - return@Button - } - - val match = queryRegex.find(queryString) - if (match != null) { - onSave(match.groupValues[2], match.groupValues[1]) - } - - } else { - onSave(userId, apiKey) - } - } - ) { - Text("Save") - } - }, - dismissButton = { - TextButton(onClick = onDismissRequest) { - Text("Cancel") - } - } - ) -} - - data class ToggleablePrefCategory( var available: Boolean = true, var enabled: Boolean, From e70370a5d5400f85bab67a940ba6899a3ba72571 Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:27:04 +0100 Subject: [PATCH 20/59] Fix result count when results contain unsupported media --- .../moe/apex/breadboard/saucenao/SauceNao.kt | 27 ++++++++++++------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/moe/apex/breadboard/saucenao/SauceNao.kt b/app/src/main/java/moe/apex/breadboard/saucenao/SauceNao.kt index 6e8f83bc..125de0f7 100644 --- a/app/src/main/java/moe/apex/breadboard/saucenao/SauceNao.kt +++ b/app/src/main/java/moe/apex/breadboard/saucenao/SauceNao.kt @@ -172,18 +172,20 @@ object SauceNao { val json = JSONObject(body) val headerJson = json.getJSONObject("header") - val header = SauceNaoResponseHeader( - status = headerJson.getInt("status"), - message = headerJson.optString("message", null), - resultsCount = headerJson.optInt("results_returned", -1) - ) + val status = headerJson.getInt("status") + val message = headerJson.optString("message").takeIf { it.isNotEmpty() } + var resultsCount = headerJson.optInt("results_returned", 0) - if (header.status != 0) { - val errorMessage = header.message ?: "SauceNAO returned status ${header.status}" - throw SauceNaoException(errorMessage, header.status) + if (status != 0) { + val errorMessage = message ?: "SauceNAO returned status $status" + throw SauceNaoException(errorMessage, status) } - val resultsJson = json.optJSONArray("results") ?: return SauceNaoResponse(header, emptyList()) + val resultsJson = json.optJSONArray("results") ?: return SauceNaoResponse( + header = SauceNaoResponseHeader(status, message, resultsCount), + results = emptyList() + ) + val results = mutableListOf() for (i in 0 until resultsJson.length()) { @@ -245,12 +247,17 @@ object SauceNao { } else { it } - } ?: continue, // TODO: No artist name means it's probably from a show or media. We'll skip them for now, but should revisit. Index ID would confirm. + } ?: run { + resultsCount -= 1 + continue // TODO: No artist name means it's probably from a show or media. We'll skip them for now, but should revisit. Index ID would confirm. + }, ) results.add(SauceNaoResult(resultHeader, resultData)) } + val header = SauceNaoResponseHeader(status, message, resultsCount) + return SauceNaoResponse(header, results) } } From 13fa5914eb08d8fcebf04ca8832a60128c920a57 Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Mon, 10 Aug 2026 01:57:04 +0100 Subject: [PATCH 21/59] Update M3 (fix dropdown back crash) --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 40251384..ef1ba210 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,7 +8,7 @@ kotlin-bom = "2.4.0" activity-compose = "1.13.0" core-ktx = "1.19.0" compose-bom = "2026.06.01" -material3 = "1.5.0-alpha23" +material3 = "1.5.0-alpha25" documentfile = "1.1.0" navigation-compose = "2.9.8" paging-compose = "3.5.0" From d7ec30f18b0a49cec8e98faa40896cc6f3c42ab7 Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Mon, 10 Aug 2026 03:04:00 +0100 Subject: [PATCH 22/59] Remove deprecated ImageGrid --- .../apex/breadboard/detailview/ImageGrid.kt | 49 +++---------------- .../breadboard/detailview/SearchResults.kt | 40 ++++++++++----- .../apex/breadboard/favourites/Favourites.kt | 48 ++++++++++++------ .../main/java/moe/apex/breadboard/util/Ui.kt | 13 +++++ 4 files changed, 79 insertions(+), 71 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 ae6fb424..85df5263 100644 --- a/app/src/main/java/moe/apex/breadboard/detailview/ImageGrid.kt +++ b/app/src/main/java/moe/apex/breadboard/detailview/ImageGrid.kt @@ -47,7 +47,6 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.painter.ColorPainter import androidx.compose.ui.layout.ContentScale 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 @@ -56,7 +55,8 @@ import coil3.request.ImageRequest import coil3.request.crossfade import kotlinx.coroutines.launch import moe.apex.breadboard.image.Image -import moe.apex.breadboard.preferences.LocalPreferences +import moe.apex.breadboard.util.MEDIUM_SPACER +import moe.apex.breadboard.util.MediumEmphasisCenteredLabel import moe.apex.breadboard.util.NavBarHeightVerticalSpacer import moe.apex.breadboard.util.PullToRefreshController import moe.apex.breadboard.util.SMALL_SPACER @@ -102,11 +102,10 @@ object FlexibleImageGridDefaults { @Composable - fun NoImages() { - Text( - text = "No images :(", - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth() + fun NoImages(text: String = "No matching posts :(") { + MediumEmphasisCenteredLabel( + text = text, + modifier = Modifier.padding(MEDIUM_SPACER.dp) ) } } @@ -285,42 +284,6 @@ fun FlexibleImageGrid( } -@Deprecated( - message = "Use FlexibleImageGrid, which allows for passing in multiple header items, " + - "custom loading indicators, and controlling whether user scroll is allowed, instead.", - replaceWith = ReplaceWith("FlexibleImageGrid") -) -@Composable -fun ImageGrid( - modifier: Modifier = Modifier, - staggeredGridState: LazyStaggeredGridState = rememberLazyStaggeredGridState(), - uniformGridState: LazyGridState = rememberLazyGridState(), - images: List, - onImageClick: (Int, Image) -> Unit, - noImagesContent: @Composable () -> Unit = FlexibleImageGridDefaults::NoImages, - contentPadding: PaddingValues = PaddingValues(0.dp), - filterComposable: (@Composable () -> Unit)? = null, - pullToRefreshController: PullToRefreshController? = null, - doneInitialLoad: Boolean = true, - onEndReached: (suspend () -> Unit)? = null -) { - val prefs = LocalPreferences.current - - FlexibleImageGrid( - gridState = if (prefs.useStaggeredGrid) staggeredGridState else uniformGridState, - modifier = modifier, - images = images, - onImageClick = onImageClick, - noImagesContent = noImagesContent, - contentPadding = contentPadding, - headerItems = filterComposable?.let { { item { it() }} }, - pullToRefreshController = pullToRefreshController, - doneInitialLoad = doneInitialLoad, - onEndReached = onEndReached - ) -} - - @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 3ad90c5d..6565d248 100644 --- a/app/src/main/java/moe/apex/breadboard/detailview/SearchResults.kt +++ b/app/src/main/java/moe/apex/breadboard/detailview/SearchResults.kt @@ -57,7 +57,6 @@ import moe.apex.breadboard.util.TitleSummary import moe.apex.breadboard.util.availableRatingsForCurrentSource import moe.apex.breadboard.util.filterChipSolidColor import moe.apex.breadboard.util.rememberPullToRefreshController -import moe.apex.breadboard.util.withoutVertical import moe.apex.breadboard.viewmodel.SearchResultsViewModel @@ -86,6 +85,11 @@ fun SearchResults(navController: NavController, source: ImageSource, tagList: Li val viewModelImages by viewModel.images.collectAsStateWithLifecycle() val blockedTags by viewModel.blockedTags.collectAsStateWithLifecycle() val selectedRatings by viewModel.selectedRatings.collectAsStateWithLifecycle() + val state = if (prefs.useStaggeredGrid) { + viewModel.staggeredGridState + } else { + viewModel.uniformGridState + } fun setUpViewModel(auth: ImageBoardAuth? = null) { viewModel.setup( @@ -175,7 +179,7 @@ fun SearchResults(navController: NavController, source: ImageSource, tagList: Li additionalActions = { if (doneInitialLoad) { ScrollToTopArrow( - scrollableState = if (prefs.useStaggeredGrid) viewModel.staggeredGridState else viewModel.uniformGridState, + scrollableState = state, animate = !filterLocally || Experiment.ALWAYS_ANIMATE_SCROLL.isEnabled(), ) } @@ -206,12 +210,11 @@ fun SearchResults(navController: NavController, source: ImageSource, tagList: Li return@MainScreenScaffold } - ImageGrid( + FlexibleImageGrid( + gridState = state, modifier = Modifier - .padding(padding.withoutVertical(top = false)) + .padding(padding) .nestedScroll(scrollBehavior.nestedScrollConnection), - staggeredGridState = viewModel.staggeredGridState, - uniformGridState = viewModel.uniformGridState, images = imagesToDisplay, onImageClick = { index, _ -> Snapshot.withMutableSnapshot { @@ -219,14 +222,25 @@ fun SearchResults(navController: NavController, source: ImageSource, tagList: Li isImageCarouselVisible = true } }, + noImagesContent = { + if (selectedRatings.isEmpty()) { + FlexibleImageGridDefaults.NoImages("No ratings selected.") + } else { + FlexibleImageGridDefaults.NoImages() + } + }, contentPadding = PaddingValues(top = SMALL_LARGE_SPACER.dp, start = SMALL_LARGE_SPACER.dp, end = SMALL_LARGE_SPACER.dp), - filterComposable = if (filterLocally) { { - HorizontallyScrollingChipsWithLabels( - modifier = Modifier.padding(bottom = TINY_SPACER.dp), - labels = listOf("Ratings"), - content = listOf(ratingRows) - ) - } } else null, + headerItems = { + if (filterLocally) { + item { + HorizontallyScrollingChipsWithLabels( + modifier = Modifier.padding(bottom = TINY_SPACER.dp), + labels = listOf("Ratings"), + content = listOf(ratingRows) + ) + } + } + }, pullToRefreshController = pullToRefreshController, doneInitialLoad = doneInitialLoad, onEndReached = viewModel::loadMore 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 47208807..ca572e0d 100644 --- a/app/src/main/java/moe/apex/breadboard/favourites/Favourites.kt +++ b/app/src/main/java/moe/apex/breadboard/favourites/Favourites.kt @@ -22,7 +22,8 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController import kotlinx.coroutines.launch -import moe.apex.breadboard.detailview.ImageGrid +import moe.apex.breadboard.detailview.FlexibleImageGrid +import moe.apex.breadboard.detailview.FlexibleImageGridDefaults import moe.apex.breadboard.image.ImageRating import moe.apex.breadboard.preferences.Experiment import moe.apex.breadboard.preferences.ImageSource @@ -65,6 +66,12 @@ fun FavouritesPage( else it.metadata.rating in prefs.favouritesRatingsFilter } + val state = if (prefs.useStaggeredGrid) { + viewModel.staggeredGridState + } else { + viewModel.uniformGridState + } + val chips = mutableListOf Unit>>() chips.add(ImageSource.entries.map { { FilterChip( @@ -131,18 +138,14 @@ fun FavouritesPage( } } ) { padding -> - ImageGrid( + FlexibleImageGrid( + gridState = state, modifier = Modifier .padding(padding) .nestedScroll(scrollBehavior.nestedScrollConnection) - .onScroll(viewModel.staggeredGridState) { + .onScroll(state) { navBarVisibilityCallback(!it.lastScrolledForward) - } - .onScroll(viewModel.uniformGridState) { - navBarVisibilityCallback(!it.lastScrolledForward) - }, - staggeredGridState = viewModel.staggeredGridState, - uniformGridState = viewModel.uniformGridState, + }, images = images, onImageClick = { index, _ -> Snapshot.withMutableSnapshot { @@ -150,13 +153,28 @@ fun FavouritesPage( isImageCarouselVisible = true } }, - contentPadding = PaddingValues(top = SMALL_LARGE_SPACER.dp, start = SMALL_LARGE_SPACER.dp, end = SMALL_LARGE_SPACER.dp, bottom = bottomAppBarAndNavBarHeight), - filterComposable = { - HorizontallyScrollingChipsWithLabels( - modifier = Modifier.padding(bottom = TINY_SPACER.dp), - labels = listOf("Sources", "Ratings"), - content = chips + noImagesContent = { + FlexibleImageGridDefaults.NoImages( + text = if (prefs.favouritesFilter.isEmpty() && prefs.favouritesRatingsFilter.isEmpty()) { + "No sources or ratings selected." + } else if (prefs.favouritesFilter.isEmpty()) { + "No sources selected." + } else if (prefs.favouritesRatingsFilter.isEmpty()) { + "No ratings selected." + } else { + "No favourite posts yet.\nStart browsing to find your new favourite art!" + } ) + }, + contentPadding = PaddingValues(top = SMALL_LARGE_SPACER.dp, start = SMALL_LARGE_SPACER.dp, end = SMALL_LARGE_SPACER.dp, bottom = bottomAppBarAndNavBarHeight), + headerItems = { + item { + HorizontallyScrollingChipsWithLabels( + modifier = Modifier.padding(bottom = TINY_SPACER.dp), + labels = listOf("Sources", "Ratings"), + content = chips + ) + } } ) } 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 83570520..c9dcea95 100644 --- a/app/src/main/java/moe/apex/breadboard/util/Ui.kt +++ b/app/src/main/java/moe/apex/breadboard/util/Ui.kt @@ -422,6 +422,19 @@ fun MainScreenScaffold( } +@Composable +fun MediumEmphasisCenteredLabel( + text: String, + modifier: Modifier = Modifier +) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() then modifier + ) +} @Composable From 28a35d5ad6c50a8f2655650dd5291a6fd910d607 Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Mon, 10 Aug 2026 03:26:26 +0100 Subject: [PATCH 23/59] Apparently I forgot to commit this lol --- .../saucenao/SauceNaoResultGroup.kt | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 app/src/main/java/moe/apex/breadboard/saucenao/SauceNaoResultGroup.kt diff --git a/app/src/main/java/moe/apex/breadboard/saucenao/SauceNaoResultGroup.kt b/app/src/main/java/moe/apex/breadboard/saucenao/SauceNaoResultGroup.kt new file mode 100644 index 00000000..a999f857 --- /dev/null +++ b/app/src/main/java/moe/apex/breadboard/saucenao/SauceNaoResultGroup.kt @@ -0,0 +1,134 @@ +package moe.apex.breadboard.saucenao + +import moe.apex.breadboard.social.SocialSite +import moe.apex.breadboard.navigation.ImageView + + +/** + * A group of [SauceNaoResult]s that (probably) represent the same artwork. + * + * The [primaryResult] is the result with the highest similarity in the group. + * [relatedResults] are the remaining results, sorted by descending similarity. + * + * [mergedUrls] and [mergedImageBoards] aggregate the links from all results. + */ +data class SauceNaoResultGroup( + val primaryResult: SauceNaoResult, + val relatedResults: List, + val mergedUrls: Map>, + val mergedImageBoards: List +) + + +/** + * Groups a flat list of [SauceNaoResult]s by attempting to discern if they are the same work. + * + * Two results are considered the same work if they share: + * - The same non-null [SauceNaoResultData.pixivId], or + * - Any overlapping URL in their [SauceNaoResultData.extUrls] + * + * Results that don't match with any other result become groups with only a [SauceNaoResultGroup.primaryResult]. + * Groups are returned in the order of their primary result's position in the input list. + */ +fun groupResults(results: List): List { + /* This whole thing is awkward. + Unfortunately it's already late and I don't know what I'm doing any more. + Future me can revisit at some point, but for now we're keeping it. */ + + if (results.isEmpty()) return emptyList() + + val parent = IntArray(results.size) { it } + + fun find(x: Int): Int { + var root = x + while (parent[root] != root) root = parent[root] + var curr = x + while (curr != root) { + val next = parent[curr] + parent[curr] = root + curr = next + } + return root + } + + fun union(a: Int, b: Int) { + val rootA = find(a) + val rootB = find(b) + if (rootA != rootB) parent[rootA] = rootB + } + + // Dumb + val pixivIdToIndex = mutableMapOf() + val urlToIndex = mutableMapOf() + + for (i in results.indices) { + val data = results[i].data + + // Start grouping using the pixiv id + data.pixivId?.let { pxId -> + val existing = pixivIdToIndex[pxId] + if (existing != null) { + union(i, existing) + } else { + pixivIdToIndex[pxId] = i + } + } + + // Group by any shared extUrls (which can also contain the source url) + for (url in data.extUrls) { + val existing = urlToIndex[url] + if (existing != null) { + union(i, existing) + } else { + urlToIndex[url] = i + } + } + } + + // Collect groups, root -> list of indices + val groups = mutableMapOf>() + for (i in results.indices) { + groups.getOrPut(find(i)) { mutableListOf() }.add(i) + } + + // Build SauceNaoResultGroups, ordered by the earliest index in each group + return groups.values + .sortedBy { it.min() } + .map { indices -> + val sorted = indices.sortedByDescending { results[it].header.similarity } + val primary = results[sorted.first()] + val related = sorted.drop(1).map { results[it] } + + val allResults = sorted.map { results[it] } + + // Merge social URLs from all results in the group + val mergedUrls = mutableMapOf>() + for (result in allResults) { + for ((site, urls) in result.data.mapUrls()) { + val existing = mergedUrls.getOrPut(site) { mutableListOf() } + for (url in urls) { + if (url !in existing) { + existing.add(url) + } + } + } + } + + // Also merge the provided imageboard sources + val mergedImageBoards = mutableListOf() + for (result in allResults) { + for (imageView in result.data.parseImageBoards()) { + if (mergedImageBoards.none { it.source == imageView.source && it.id == imageView.id }) { + mergedImageBoards.add(imageView) + } + } + } + + SauceNaoResultGroup( + primaryResult = primary, + relatedResults = related, + mergedUrls = mergedUrls, + mergedImageBoards = mergedImageBoards + ) + } +} From d046b7583e16154e38d60030dade2982c8cecf49 Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Mon, 10 Aug 2026 03:44:21 +0100 Subject: [PATCH 24/59] Update style of no recommendation texts --- .../moe/apex/breadboard/home/HomeScreen.kt | 54 +++++++++---------- 1 file changed, 27 insertions(+), 27 deletions(-) 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 8194a1a2..deacee1a 100644 --- a/app/src/main/java/moe/apex/breadboard/home/HomeScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/home/HomeScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState @@ -27,6 +28,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Person import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.FilledIconButton @@ -35,7 +37,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.material3.ToggleButton import androidx.compose.material3.ToggleButtonDefaults import androidx.compose.material3.TopAppBarDefaults @@ -78,7 +79,6 @@ import moe.apex.breadboard.ui.theme.prefTitle import moe.apex.breadboard.util.BasicExpressiveContainer import moe.apex.breadboard.util.CHIP_SPACING import moe.apex.breadboard.util.FollowingProvider -import moe.apex.breadboard.util.LARGE_SPACER import moe.apex.breadboard.util.ListItemPosition import moe.apex.breadboard.util.MEDIUM_LARGE_SPACER import moe.apex.breadboard.util.MainScreenScaffold @@ -316,18 +316,20 @@ fun HomeScreen( } }, noImagesContent = { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text("No recommendations right now.") - TextButton( + Column(horizontalAlignment = Alignment.CenterHorizontally) { + FlexibleImageGridDefaults.NoImages("No recommendations right now.") + Button( onClick = { ptrController.refresh(animate = true) - } + }, + shapes = ButtonDefaults.shapes() ) { - Icon(Icons.Rounded.Refresh, contentDescription = null) - Spacer(Modifier.width(SMALL_SPACER.dp)) + Icon( + imageVector = Icons.Rounded.Refresh, + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(Modifier.width(ButtonDefaults.IconSpacing)) Text("Refresh") } } @@ -387,10 +389,7 @@ fun HomeScreen( if (!provider.doneInitialLoad) { return@FlexibleImageGrid } - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { if (prefs.followedTags.isEmpty()) { val suggestedArtists = remember(prefs.favouriteImages, prefs.followedTags) { RecommendationsHelper.getRecommendedArtists( @@ -423,24 +422,25 @@ fun HomeScreen( ) } } else { - Text( - text = "You aren't following anyone yet.", - modifier = Modifier.padding(top = LARGE_SPACER.dp) + FlexibleImageGridDefaults.NoImages( + text = "You aren't following anyone yet.\n" + + "Add some posts to your Favourites and Breadboard will suggest artists for you to follow." ) } } else { - Text( - text = "No new posts from artists you follow.", - modifier = Modifier.padding(top = LARGE_SPACER.dp) - ) - TextButton( - modifier = Modifier.padding(top = SMALL_SPACER.dp), + FlexibleImageGridDefaults.NoImages("No new posts from your followed artists,") + Button( onClick = { ptrController.refresh(animate = true) - } + }, + shapes = ButtonDefaults.shapes() ) { - Icon(Icons.Rounded.Refresh, contentDescription = null) - Spacer(Modifier.width(SMALL_SPACER.dp)) + Icon( + imageVector = Icons.Rounded.Refresh, + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(Modifier.width(ButtonDefaults.IconSpacing)) Text("Refresh") } } From 56673c448392e02ab1e51ad84a193d24daff0046 Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Mon, 10 Aug 2026 03:48:04 +0100 Subject: [PATCH 25/59] Dedicated style for body medium text --- app/src/main/java/moe/apex/breadboard/ui/theme/Type.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/java/moe/apex/breadboard/ui/theme/Type.kt b/app/src/main/java/moe/apex/breadboard/ui/theme/Type.kt index 84e298d9..6ec38bae 100644 --- a/app/src/main/java/moe/apex/breadboard/ui/theme/Type.kt +++ b/app/src/main/java/moe/apex/breadboard/ui/theme/Type.kt @@ -16,6 +16,10 @@ val Typography = Typography( lineHeight = 24.sp, letterSpacing = 0.2.sp ), + bodyMedium = Typography().bodyMedium.copy( + fontSize = 15.sp, + lineHeight = 17.sp + ), bodySmall = Typography().bodySmall.copy( fontSize = 14.sp, lineHeight = 16.sp, From 78b18b1e46781a2e97a6a5b31a8b38d37ce37e58 Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Mon, 10 Aug 2026 03:51:37 +0100 Subject: [PATCH 26/59] Replace remaining(?) non-usages of new label style --- .../breadboard/saucenao/SauceNaoResultsScreen.kt | 12 ++---------- .../java/moe/apex/breadboard/search/SearchScreen.kt | 10 +++------- 2 files changed, 5 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/moe/apex/breadboard/saucenao/SauceNaoResultsScreen.kt b/app/src/main/java/moe/apex/breadboard/saucenao/SauceNaoResultsScreen.kt index 57e5933a..87e0fd74 100644 --- a/app/src/main/java/moe/apex/breadboard/saucenao/SauceNaoResultsScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/saucenao/SauceNaoResultsScreen.kt @@ -67,6 +67,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController import coil3.compose.AsyncImage import moe.apex.breadboard.R +import moe.apex.breadboard.detailview.FlexibleImageGridDefaults import moe.apex.breadboard.social.SocialSite import moe.apex.breadboard.social.sortSocialSites import moe.apex.breadboard.image.ImageBoardRequirement @@ -206,16 +207,7 @@ fun SauceNaoResultsScreen( is ResultsState.Success -> { if (currentState.groups.isEmpty()) { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Text( - text = "No results found :(", - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } + FlexibleImageGridDefaults.NoImages("No matches found :(") } else { var expandedGroupIndex by remember { mutableIntStateOf(-1) } LazyColumn( 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 2711e93e..1ab6f3d5 100644 --- a/app/src/main/java/moe/apex/breadboard/search/SearchScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/search/SearchScreen.kt @@ -90,7 +90,6 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.core.net.toUri import androidx.navigation.NavController import kotlinx.coroutines.CancellationException @@ -137,6 +136,7 @@ import moe.apex.breadboard.util.showToast import java.text.SimpleDateFormat import java.util.Date import androidx.compose.ui.platform.LocalLocale +import moe.apex.breadboard.util.MediumEmphasisCenteredLabel import moe.apex.breadboard.viewmodel.getGlobalViewModel @@ -762,13 +762,9 @@ fun AutoCompleteTagResults( ) { if (mostRecentSuggestions.isEmpty()) { item { - Text( - fontSize = 16.sp, + MediumEmphasisCenteredLabel( text = "No results :(", - textAlign = TextAlign.Center, - modifier = Modifier - .padding(SMALL_LARGE_SPACER.dp) - .fillMaxWidth(), + modifier = Modifier.padding(SMALL_LARGE_SPACER.dp) ) } } else { From 5284a791c0a19bf566185b87591e1e9184e3c049 Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Mon, 10 Aug 2026 04:53:25 +0100 Subject: [PATCH 27/59] Ease some pain points with key and settings requirements --- .../breadboard/detailview/SearchResults.kt | 53 ++--------------- .../moe/apex/breadboard/home/HomeScreen.kt | 59 +++++++++++++++++++ .../moe/apex/breadboard/preferences/Pref.kt | 4 +- .../main/java/moe/apex/breadboard/util/Ui.kt | 55 +++++++++++++++++ 4 files changed, 123 insertions(+), 48 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 6565d248..1db5796d 100644 --- a/app/src/main/java/moe/apex/breadboard/detailview/SearchResults.kt +++ b/app/src/main/java/moe/apex/breadboard/detailview/SearchResults.kt @@ -1,13 +1,8 @@ package moe.apex.breadboard.detailview -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.fillMaxWidth 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 @@ -35,25 +30,21 @@ 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.navigation.Settings import moe.apex.breadboard.preferences.Experiment import moe.apex.breadboard.preferences.ImageSource import moe.apex.breadboard.preferences.LocalPreferences import moe.apex.breadboard.preferences.PreferenceKeys import moe.apex.breadboard.prefs import moe.apex.breadboard.util.AgeVerification -import moe.apex.breadboard.util.ExpressiveContainer import moe.apex.breadboard.util.HorizontallyScrollingChipsWithLabels import moe.apex.breadboard.util.LargeTitleBar -import moe.apex.breadboard.util.ListItemPosition -import moe.apex.breadboard.util.MEDIUM_SPACER import moe.apex.breadboard.util.MainScreenScaffold import moe.apex.breadboard.largeimageview.OffsetBasedLargeImageView +import moe.apex.breadboard.util.ApiKeyRequiredPrompt import moe.apex.breadboard.util.PullToRefreshControllerDefaults import moe.apex.breadboard.util.SMALL_LARGE_SPACER import moe.apex.breadboard.util.ScrollToTopArrow import moe.apex.breadboard.util.TINY_SPACER -import moe.apex.breadboard.util.TitleSummary import moe.apex.breadboard.util.availableRatingsForCurrentSource import moe.apex.breadboard.util.filterChipSolidColor import moe.apex.breadboard.util.rememberPullToRefreshController @@ -195,15 +186,13 @@ fun SearchResults(navController: NavController, source: ImageSource, tagList: Li } if (needsAuth) { - return@MainScreenScaffold ApiKeyRequiredColumn( + return@MainScreenScaffold ApiKeyRequiredPrompt( modifier = Modifier .padding(padding) - .padding(top = SMALL_LARGE_SPACER.dp) - .fillMaxWidth(), - source = source - ) { - navController.navigate(Settings) - } + .padding(top = SMALL_LARGE_SPACER.dp), + source = source, + navController = navController + ) } if (!isReady) { @@ -257,33 +246,3 @@ fun SearchResults(navController: NavController, source: ImageSource, tagList: Li 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/home/HomeScreen.kt b/app/src/main/java/moe/apex/breadboard/home/HomeScreen.kt index deacee1a..9cabdb15 100644 --- a/app/src/main/java/moe/apex/breadboard/home/HomeScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/home/HomeScreen.kt @@ -36,6 +36,7 @@ import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.material3.ToggleButton import androidx.compose.material3.ToggleButtonDefaults @@ -65,19 +66,24 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import moe.apex.breadboard.detailview.FlexibleImageGrid import moe.apex.breadboard.detailview.FlexibleImageGridDefaults +import moe.apex.breadboard.image.ImageBoardRequirement import moe.apex.breadboard.preferences.Experiment import moe.apex.breadboard.preferences.ImageSource import moe.apex.breadboard.preferences.LocalPreferences import moe.apex.breadboard.largeimageview.OffsetBasedLargeImageView import moe.apex.breadboard.navigation.FollowedArtists +import moe.apex.breadboard.navigation.GeneralSettings +import moe.apex.breadboard.navigation.RecommendationsSettings import moe.apex.breadboard.preferences.BrowseTab import moe.apex.breadboard.preferences.PreferenceKeys import moe.apex.breadboard.prefs import moe.apex.breadboard.tag.IgnoredTagsHelper import moe.apex.breadboard.ui.theme.BreadboardTheme import moe.apex.breadboard.ui.theme.prefTitle +import moe.apex.breadboard.util.ApiKeyRequiredPrompt import moe.apex.breadboard.util.BasicExpressiveContainer import moe.apex.breadboard.util.CHIP_SPACING +import moe.apex.breadboard.util.ExpressivePromptWithActions import moe.apex.breadboard.util.FollowingProvider import moe.apex.breadboard.util.ListItemPosition import moe.apex.breadboard.util.MEDIUM_LARGE_SPACER @@ -278,6 +284,59 @@ fun HomeScreen( userScrollEnabled = !shouldShowLargeImage ) { page -> if (page == BrowseTab.entries.indexOf(BrowseTab.FOR_YOU)) { + val needsAuth = remember { + prefs.imageSource.imageBoard.apiKeyRequirement == ImageBoardRequirement.REQUIRED && + prefs.authFor(prefs.imageSource, context) == null + } + + if (needsAuth) { + return@HorizontalPager ApiKeyRequiredPrompt( + modifier = Modifier.padding(vertical = SMALL_LARGE_SPACER.dp), + source = prefs.imageSource, + navController = navController + ) { + OutlinedButton( + onClick = { + navController.navigate(GeneralSettings) + }, + shapes = ButtonDefaults.shapes() + ) { + Text("Change source") + } + } + } + + /* R34 is overwhelmingly QUESTIONABLE or EXPLICIT rated posts. We'll just + enforce the option to prevent any confusion as to why there would be no (good) + results when disabled. */ + if (prefs.imageSource == ImageSource.R34 && !prefs.recommendAllRatings) { + return@HorizontalPager ExpressivePromptWithActions( + modifier = Modifier.padding(vertical = SMALL_LARGE_SPACER.dp), + title = "Settings adjustment needed", + summary = "Your selected source is Rule34, but you don't have " + + "all ratings enabled. Enable the 'Recommend all ratings' " + + "option to start seeing your recommendations. " + ) { + OutlinedButton( + onClick = { + navController.navigate(GeneralSettings) + }, + shapes = ButtonDefaults.shapes() + ) { + Text("Change source") + } + + Button( + onClick = { + navController.navigate(RecommendationsSettings) + }, + shapes = ButtonDefaults.shapes() + ) { + Text("Go to settings") + } + } + } + recommendationsProvider?.let { provider -> val state = if (prefs.useStaggeredGrid) { provider.staggeredGridState 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 381bf2bc..ed6ad743 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/Pref.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/Pref.kt @@ -298,7 +298,9 @@ data class Prefs( if (source == ImageSource.R34) { return ImageBoardAuth(BuildConfig.R34_APP_ID, SecretsManager.getApiKey(context)!!) } - return imageBoardAuths[source] + return imageBoardAuths[source].takeIf { + it?.user?.isNotEmpty() == true && it.apiKey.isNotEmpty() + } } 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 c9dcea95..fd53bfac 100644 --- a/app/src/main/java/moe/apex/breadboard/util/Ui.kt +++ b/app/src/main/java/moe/apex/breadboard/util/Ui.kt @@ -63,6 +63,7 @@ import androidx.compose.material.icons.rounded.ChevronRight import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.KeyboardArrowUp import androidx.compose.material3.BottomSheetDefaults +import androidx.compose.material3.Button import androidx.compose.material3.ButtonColors import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api @@ -137,7 +138,9 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import moe.apex.breadboard.history.SearchHistoryEntry +import moe.apex.breadboard.navigation.ApiKeysSettings import moe.apex.breadboard.preferences.Experiment +import moe.apex.breadboard.preferences.ImageSource import moe.apex.breadboard.preferences.LocalPreferences import moe.apex.breadboard.prefs import moe.apex.breadboard.ui.theme.BreadboardTheme @@ -1625,6 +1628,58 @@ fun WideLinearWavyProgressIndicator(modifier: Modifier = Modifier) { } +@Composable +fun ExpressivePromptWithActions( + modifier: Modifier = Modifier, + title: String, + summary: String? = null, + actions: @Composable RowScope.() -> Unit +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MEDIUM_SPACER.dp) + ) { + ExpressiveContainer(position = ListItemPosition.SINGLE_ELEMENT) { + TitleSummary( + title = title, + summary = summary, + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(MEDIUM_SPACER.dp)) { + actions() + } + } +} + + +@Composable +fun ApiKeyRequiredPrompt( + modifier: Modifier = Modifier, + source: ImageSource, + navController: NavController, + beforeActions: (@Composable RowScope.() -> Unit)? = null +) { + ExpressivePromptWithActions( + modifier = modifier, + title = "API key needed", + summary = "To use ${source.label}, you must first set an API key.\n" + + "Alternatively, choose a different source." + ) { + beforeActions?.invoke(this) + + Button( + onClick = { + navController.navigate(ApiKeysSettings) + }, + shapes = ButtonDefaults.shapes() + ) { + Text("API key settings") + } + } +} + + fun generateColours( darkTheme: Boolean, seed: Any From 1c7a4aa4fe6e0af6058cb418ddb6647b3fd2d0d0 Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Mon, 10 Aug 2026 05:03:12 +0100 Subject: [PATCH 28/59] Ease SauceNAO api key flow --- .../java/moe/apex/breadboard/saucenao/ReverseSearchScreen.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/moe/apex/breadboard/saucenao/ReverseSearchScreen.kt b/app/src/main/java/moe/apex/breadboard/saucenao/ReverseSearchScreen.kt index 8d9c34dd..4e4f51d7 100644 --- a/app/src/main/java/moe/apex/breadboard/saucenao/ReverseSearchScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/saucenao/ReverseSearchScreen.kt @@ -61,6 +61,7 @@ import coil3.request.ImageRequest import coil3.request.crossfade import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import moe.apex.breadboard.navigation.ApiKeysSettings import moe.apex.breadboard.navigation.SauceNaoResults import moe.apex.breadboard.preferences.LocalPreferences import moe.apex.breadboard.ui.theme.searchField @@ -134,7 +135,8 @@ fun ReverseSearchScreen(navController: NavController) { fun onSearch() { if (prefs.saucenaoApiKey.isEmpty()) { - showToast(context, "Set a SauceNAO API key in General Settings") + navController.navigate(ApiKeysSettings) + showToast(context, "Set a SauceNAO API key first.") return } From 9ce92e5e369af539dbdc2219e1725e100f3e3b37 Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Mon, 10 Aug 2026 05:23:24 +0100 Subject: [PATCH 29/59] Update settings category descriptions. --- .../java/moe/apex/breadboard/preferences/Preferences.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 45e5584d..c12d8990 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/Preferences.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/Preferences.kt @@ -108,7 +108,7 @@ fun PreferencesScreen(navController: NavHostController) { TitleSummary( modifier = Modifier.fillMaxWidth(), title = "General", - summary = "Image source, API keys, search history, and links.", + summary = "Image source, search history, and links.", leadingIcon = { ContainedIcon(imageVector = Icons.Rounded.Settings, label = "General") }, @@ -134,7 +134,7 @@ fun PreferencesScreen(navController: NavHostController) { TitleSummary( modifier = Modifier.fillMaxWidth(), title = "Content", - summary = "Blocked tags, recommendations, and video settings.", + summary = "Blocked tags, recommendations, AI filtering, and video settings.", leadingIcon = { ContainedIcon(imageVector = Icons.Rounded.Visibility, label = "Content") }, @@ -147,7 +147,7 @@ fun PreferencesScreen(navController: NavHostController) { TitleSummary( modifier = Modifier.fillMaxWidth(), title = "Behaviour and layout", - summary = "Start page, app layout, and image viewer actions.", + summary = "Start page, data saver, visual and layout preferences.", leadingIcon = { ContainedIcon(imageVector = Icons.Rounded.Dashboard, label = "Behaviour and layout") }, From 9bb657efd3d4d02d35f011b69084bfb7ba3453fa Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Mon, 10 Aug 2026 18:14:32 +0100 Subject: [PATCH 30/59] Fix crash when no SAFE posts in artist profile, hide board link chip --- .../breadboard/artist/ArtistProfileScreen.kt | 65 ++++++++++++------- 1 file changed, 40 insertions(+), 25 deletions(-) diff --git a/app/src/main/java/moe/apex/breadboard/artist/ArtistProfileScreen.kt b/app/src/main/java/moe/apex/breadboard/artist/ArtistProfileScreen.kt index f26af58d..3f94481b 100644 --- a/app/src/main/java/moe/apex/breadboard/artist/ArtistProfileScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/artist/ArtistProfileScreen.kt @@ -71,6 +71,7 @@ import androidx.compose.ui.draw.blur import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.compositeOver import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.painter.ColorPainter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext @@ -109,6 +110,7 @@ import moe.apex.breadboard.util.SMALL_SPACER import moe.apex.breadboard.util.Summary import moe.apex.breadboard.util.TINY_SPACER import moe.apex.breadboard.util.copyText +import moe.apex.breadboard.util.generateColours import moe.apex.breadboard.util.showToast import moe.apex.breadboard.viewmodel.ArtistProfileViewModel import moe.apex.breadboard.viewmodel.getGlobalViewModel @@ -497,31 +499,42 @@ private fun ArtistHeader( images: List, ) { val uriHandler = LocalUriHandler.current - val randomImageIndex = rememberSaveable { Random.nextInt(images.size) } val groupedSocials = remember { artist.groupSocials() } BreadboardTheme(darkTheme = true) { // We're adding a dark scrim, so assume always dark theme. Surface(shape = MaterialTheme.shapes.extraLargeIncreased) { Box { - AsyncImage( - model = images[randomImageIndex].let { - if (it.isVideo || it.fileFormat == "gif" || it.fileFormat == "webp") { - it.previewUrl // Can't use videos, and we don't want animated images - } else { - it.sampleUrl // Highest quality isn't needed when we're blurring it anyway - } - }, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .matchParentSize() - .then( - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { // Android 12 - Modifier.blur(radius = 16.dp) - } else Modifier - ) - ) + if (images.isNotEmpty()) { + val randomImageIndex = rememberSaveable { Random.nextInt(images.size) } + AsyncImage( + model = images[randomImageIndex].let { + if (it.isVideo || it.fileFormat == "gif" || it.fileFormat == "webp") { + it.previewUrl // Can't use videos, and we don't want animated images + } else { + it.sampleUrl // Highest quality isn't needed when we're blurring it anyway + } + }, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .matchParentSize() + .then( + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { // Android 12 + Modifier.blur(radius = 16.dp) + } else Modifier + ) + ) + } else { + // If no images to show, just show a solid colour based on their name. + val painter = remember { ColorPainter(generateColours(false, artist.name).first) } + androidx.compose.foundation.Image( + painter = painter, + contentDescription = null, + modifier = Modifier.matchParentSize() + ) + } + Column( modifier = Modifier .background(Color.Black.copy(alpha = 0.5f)) @@ -554,12 +567,14 @@ private fun ArtistHeader( horizontalArrangement = Arrangement.spacedBy(MEDIUM_SPACER.dp) ) { groupedSocials.forEach { (site, socials) -> - item { - SocialChip( - site = site, - socialProfiles = socials, - onClick = { uriHandler.openUri(it) } - ) + if (site != SocialSite.IMAGEBOARD) { + item { + SocialChip( + site = site, + socialProfiles = socials, + onClick = { uriHandler.openUri(it) } + ) + } } } } From 5b4cfcaeddf0e32889432cc0f57cbea8b6df8190 Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Mon, 10 Aug 2026 18:21:23 +0100 Subject: [PATCH 31/59] punctuation fix --- app/src/main/java/moe/apex/breadboard/home/HomeScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 9cabdb15..4b20da0d 100644 --- a/app/src/main/java/moe/apex/breadboard/home/HomeScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/home/HomeScreen.kt @@ -487,7 +487,7 @@ fun HomeScreen( ) } } else { - FlexibleImageGridDefaults.NoImages("No new posts from your followed artists,") + FlexibleImageGridDefaults.NoImages("No new posts from your followed artists.") Button( onClick = { ptrController.refresh(animate = true) From 5500995bed88840cd7f8b5f9160cb69ee98b9ae9 Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:18:25 +0100 Subject: [PATCH 32/59] Add more useful message when no safe results --- .../java/moe/apex/breadboard/artist/ArtistProfileScreen.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/src/main/java/moe/apex/breadboard/artist/ArtistProfileScreen.kt b/app/src/main/java/moe/apex/breadboard/artist/ArtistProfileScreen.kt index 3f94481b..93931ee0 100644 --- a/app/src/main/java/moe/apex/breadboard/artist/ArtistProfileScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/artist/ArtistProfileScreen.kt @@ -89,6 +89,7 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch import moe.apex.breadboard.R import moe.apex.breadboard.detailview.FlexibleImageGrid +import moe.apex.breadboard.detailview.FlexibleImageGridDefaults import moe.apex.breadboard.image.Image import moe.apex.breadboard.largeimageview.OffsetBasedLargeImageView import moe.apex.breadboard.navigation.Results @@ -221,6 +222,12 @@ fun ArtistProfileScreen( item { PopularPostsHeading() } + }, + noImagesContent = { + FlexibleImageGridDefaults.NoImages( + text = "We weren't able to show any posts here.\n" + + "Tap the button to search all posts." + ) } ) } From e3f10ad2d2a86c67548d0a1183122008def0ee93 Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:38:02 +0100 Subject: [PATCH 33/59] Artist profile split view on large screens --- .../breadboard/artist/ArtistProfileScreen.kt | 183 ++++++++++++++---- .../apex/breadboard/detailview/ImageGrid.kt | 14 +- 2 files changed, 152 insertions(+), 45 deletions(-) diff --git a/app/src/main/java/moe/apex/breadboard/artist/ArtistProfileScreen.kt b/app/src/main/java/moe/apex/breadboard/artist/ArtistProfileScreen.kt index 93931ee0..a99e6d6c 100644 --- a/app/src/main/java/moe/apex/breadboard/artist/ArtistProfileScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/artist/ArtistProfileScreen.kt @@ -16,6 +16,8 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -27,9 +29,11 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CornerSize import androidx.compose.foundation.text.TextAutoSize +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Block import androidx.compose.material.icons.rounded.ContentCopy @@ -75,7 +79,9 @@ import androidx.compose.ui.graphics.painter.ColorPainter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily @@ -112,6 +118,7 @@ import moe.apex.breadboard.util.Summary import moe.apex.breadboard.util.TINY_SPACER import moe.apex.breadboard.util.copyText import moe.apex.breadboard.util.generateColours +import moe.apex.breadboard.util.navBarHeight import moe.apex.breadboard.util.showToast import moe.apex.breadboard.viewmodel.ArtistProfileViewModel import moe.apex.breadboard.viewmodel.getGlobalViewModel @@ -134,6 +141,14 @@ fun ArtistProfileScreen( val context = LocalContext.current val prefs = LocalPreferences.current + val windowInfo = LocalWindowInfo.current + val density = LocalDensity.current + + val isWideScreen = remember { + with(density) { + windowInfo.containerSize.width.toDp() >= 840.dp // M3 spec for large device in landscape + } + } var shouldShowLargeImage by remember { mutableStateOf(false) } var selectedImageIndex by remember { mutableIntStateOf(0) } @@ -193,43 +208,26 @@ fun ArtistProfileScreen( ) } } + } else if (isWideScreen) { + SplitProfileLayout( + navController = navController, + artist = artist, + artistTag = artistTag, + images = images + ) { index, _ -> + selectedImageIndex = index + shouldShowLargeImage = true + } } else { - FlexibleImageGrid( - staggered = prefs.useStaggeredGrid, - contentPadding = PaddingValues( - start = SMALL_LARGE_SPACER.dp, - end = SMALL_LARGE_SPACER.dp, - bottom = 88.dp // FAB height + 16dp vertical padding - ), - images = images, - onImageClick = { index, _ -> - selectedImageIndex = index - shouldShowLargeImage = true - }, - headerItems = { - item { - NavigationButtonBox( - modifier = Modifier.offset(x = -TINY_SPACER.dp), - navController = navController - ) - } - item { - ArtistHeader(artist!!, images) - } - item { - ArtistToolbar(artistTag) - } - item { - PopularPostsHeading() - } - }, - noImagesContent = { - FlexibleImageGridDefaults.NoImages( - text = "We weren't able to show any posts here.\n" + - "Tap the button to search all posts." - ) - } - ) + SinglePaneProfileLayout( + navController = navController, + artist = artist, + artistTag = artistTag, + images = images + ) { index, _ -> + selectedImageIndex = index + shouldShowLargeImage = true + } } } @@ -246,6 +244,113 @@ fun ArtistProfileScreen( } +@Composable +private fun SinglePaneProfileLayout( + navController: NavController, + artist: Artist?, + artistTag: String, + images: List, + onImageClick: (Int, Image) -> Unit +) { + val prefs = LocalPreferences.current + + FlexibleImageGrid( + staggered = prefs.useStaggeredGrid, + contentPadding = PaddingValues( + start = SMALL_LARGE_SPACER.dp, + end = SMALL_LARGE_SPACER.dp, + bottom = 88.dp // FAB height + 16dp vertical padding + ), + images = images, + onImageClick = onImageClick, + headerItems = { + item { + NavigationButtonBox( + modifier = Modifier.offset(x = -TINY_SPACER.dp), + navController = navController + ) + } + item { + ArtistHeader(artist!!, images) + } + item { + ArtistToolbar(artistTag) + } + item { + PopularPostsHeading() + } + }, + noImagesContent = { + FlexibleImageGridDefaults.NoImages( + text = "We weren't able to show any posts here.\n" + + "Tap the button to search all posts." + ) + } + ) +} + + +@Composable +private fun SplitProfileLayout( + navController: NavController, + artist: Artist?, + artistTag: String, + images: List, + onImageClick: (Int, Image) -> Unit +) { + val prefs = LocalPreferences.current + + Row(modifier = Modifier.fillMaxSize()) { + Column( + verticalArrangement = Arrangement.spacedBy(SMALL_SPACER.dp), + modifier = Modifier + .weight(1f) + .fillMaxHeight() + .verticalScroll(rememberScrollState()) + .padding( + start = SMALL_LARGE_SPACER.dp, + end = SMALL_LARGE_SPACER.dp, + bottom = navBarHeight + SMALL_LARGE_SPACER.dp + ) + ) { + NavigationButtonBox( + modifier = Modifier.offset(x = -TINY_SPACER.dp), + navController = navController + ) + ArtistHeader(artist!!, images) + ArtistToolbar(artistTag) + } + FlexibleImageGrid( + modifier = Modifier + .weight(1f) + .fillMaxHeight(), + staggered = prefs.useStaggeredGrid, + contentPadding = PaddingValues( + top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + + TopAppBarDefaults.LargeAppBarCollapsedHeight + + SMALL_SPACER.dp, + bottom = 88.dp, // FAB height + 16dp vertical padding + start = SMALL_LARGE_SPACER.dp, + end = SMALL_LARGE_SPACER.dp + ), + images = images, + onImageClick = onImageClick, + headerItems = { + item { + PopularPostsHeading(withDivider = false) + } + }, + noImagesContent = { + FlexibleImageGridDefaults.NoImages( + text = "We weren't able to show any posts here.\n" + + "Tap the button to search all posts." + ) + } + ) + } +} + + @Composable private fun ArtistToolbar(artistTag: String, ) { val context = LocalContext.current @@ -463,7 +568,7 @@ private fun ArtistToolbar(artistTag: String, ) { @Composable -private fun PopularPostsHeading() { +private fun PopularPostsHeading(withDivider: Boolean = true) { Column( modifier = Modifier .fillMaxWidth() @@ -471,7 +576,9 @@ private fun PopularPostsHeading() { horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(LARGE_SPACER.dp) ) { - HorizontalDivider(Modifier.fillMaxWidth(0.5f)) + if (withDivider) { + HorizontalDivider(Modifier.fillMaxWidth(0.5f)) + } Text( text = "Popular posts", style = MaterialTheme.typography.titleLarge 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 85df5263..b7d61d97 100644 --- a/app/src/main/java/moe/apex/breadboard/detailview/ImageGrid.kt +++ b/app/src/main/java/moe/apex/breadboard/detailview/ImageGrid.kt @@ -220,13 +220,13 @@ fun FlexibleImageGrid( when (gridState) { is LazyStaggeredGridState -> { - Box { + Box(modifier = modifier) { ImageGridPullToRefreshContainer( - modifier = modifier, + modifier = Modifier.fillMaxSize(), pullToRefreshController = pullToRefreshController ) { StaggeredImageGrid( - modifier = if (pullToRefreshController == null) modifier else Modifier, + modifier = Modifier.fillMaxSize(), userScrollEnabled = userScrollEnabled, gridState = gridState, contentPadding = contentPadding, @@ -239,7 +239,7 @@ fun FlexibleImageGrid( } LoadingIndicatorContainer( - modifier = modifier, + modifier = Modifier.fillMaxSize(), doneInitialLoad = doneInitialLoad, contentPadding = contentPadding, onInitialLoadCompleted = { gridState.requestScrollToItem(0) }, @@ -253,9 +253,9 @@ fun FlexibleImageGrid( modifier = modifier, pullToRefreshController = pullToRefreshController ) { - Box { + Box(modifier = Modifier.fillMaxSize()) { UniformImageGrid( - modifier = if (pullToRefreshController == null) modifier else Modifier, + modifier = Modifier.fillMaxSize(), userScrollEnabled = userScrollEnabled, gridState = gridState, contentPadding = contentPadding, @@ -267,7 +267,7 @@ fun FlexibleImageGrid( ) LoadingIndicatorContainer( - modifier = modifier, + modifier = Modifier.fillMaxSize(), doneInitialLoad = doneInitialLoad, contentPadding = contentPadding, onInitialLoadCompleted = { gridState.scrollToItem(0) }, From 3f373e10d2f1933052bcd1d59e77000790403b6d Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:15:33 +0100 Subject: [PATCH 34/59] animate potentially explicit warning --- .../java/moe/apex/breadboard/saucenao/SauceNaoResultsScreen.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/java/moe/apex/breadboard/saucenao/SauceNaoResultsScreen.kt b/app/src/main/java/moe/apex/breadboard/saucenao/SauceNaoResultsScreen.kt index 87e0fd74..20484194 100644 --- a/app/src/main/java/moe/apex/breadboard/saucenao/SauceNaoResultsScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/saucenao/SauceNaoResultsScreen.kt @@ -331,6 +331,7 @@ fun SauceNaoResultsScreen( color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, modifier = Modifier + .animateItem() .fillMaxWidth() .padding(MEDIUM_SPACER.dp) ) From ae6356b4d31866ac8c481c48b8646665f670b49a Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Tue, 11 Aug 2026 04:00:33 +0100 Subject: [PATCH 35/59] Add Whats New and add to About Settings with changes --- .../apex/breadboard/navigation/Navigation.kt | 3 + .../breadboard/preferences/AboutScreen.kt | 72 ++++--- .../moe/apex/breadboard/preferences/Pref.kt | 7 + .../java/moe/apex/breadboard/util/WhatsNew.kt | 188 ++++++++++++++++++ 4 files changed, 238 insertions(+), 32 deletions(-) create mode 100644 app/src/main/java/moe/apex/breadboard/util/WhatsNew.kt diff --git a/app/src/main/java/moe/apex/breadboard/navigation/Navigation.kt b/app/src/main/java/moe/apex/breadboard/navigation/Navigation.kt index d49450dc..1a70e670 100644 --- a/app/src/main/java/moe/apex/breadboard/navigation/Navigation.kt +++ b/app/src/main/java/moe/apex/breadboard/navigation/Navigation.kt @@ -64,6 +64,7 @@ import moe.apex.breadboard.ui.theme.BreadboardTheme import moe.apex.breadboard.util.withoutVertical import moe.apex.breadboard.home.FollowedArtistsScreen import moe.apex.breadboard.preferences.ApiKeysSettingsScreen +import moe.apex.breadboard.util.WhatsNew @Composable @@ -117,6 +118,8 @@ fun Navigation(navController: NavHostController, startDestination: Any = Search) } BreadboardTheme { + WhatsNew() + Scaffold( bottomBar = { AnimatedVisibility( 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..77503354 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/AboutScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/AboutScreen.kt @@ -2,7 +2,6 @@ package moe.apex.breadboard.preferences import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -45,13 +44,13 @@ import moe.apex.breadboard.BuildConfig import moe.apex.breadboard.R import moe.apex.breadboard.navigation.LibrariesSettings import moe.apex.breadboard.util.ChevronRight -import moe.apex.breadboard.util.ExpressiveGroup -import moe.apex.breadboard.util.LARGE_SPACER import moe.apex.breadboard.util.LargeTitleBar +import moe.apex.breadboard.util.LazyExpressiveGroup import moe.apex.breadboard.util.MainScreenScaffold import moe.apex.breadboard.util.SmallVerticalSpacer import moe.apex.breadboard.util.MEDIUM_SPACER import moe.apex.breadboard.util.TitleSummary +import moe.apex.breadboard.util.WhatsNewState import moe.apex.breadboard.util.openUrl import moe.apex.breadboard.util.releasePlatform @@ -101,8 +100,7 @@ fun AboutScreen(navController: NavHostController) { .fillMaxSize() .padding(it) .nestedScroll(scrollBehavior.nestedScrollConnection), - contentPadding = PaddingValues(vertical = MEDIUM_SPACER.dp), - verticalArrangement = Arrangement.spacedBy(LARGE_SPACER.dp) + contentPadding = PaddingValues(MEDIUM_SPACER.dp) ) { item { var isMonochrome by remember { mutableStateOf(false) } @@ -120,7 +118,10 @@ fun AboutScreen(navController: NavHostController) { .background(MaterialTheme.colorScheme.surfaceVariant) .clickable { if (easterEggCounter == 5) { - openUrl(context, "https://www.youtube.com/watch?v=3ZeHmdJnny4") // 🐟 + openUrl( + context, + "https://www.youtube.com/watch?v=3ZeHmdJnny4" + ) // 🐟 easterEggCounter = 0 } else { easterEggCounter++ @@ -155,39 +156,46 @@ fun AboutScreen(navController: NavHostController) { ) } } - item { - ExpressiveGroup("Maintainer") { - item { - GitHubUserContainer(apex2504) - } + + LazyExpressiveGroup("Maintainer", desiredTopPadding = null) { + item { + GitHubUserContainer(apex2504) } } - item { - ExpressiveGroup("Original concept") { - item { - GitHubUserContainer(devoxin) - } + + LazyExpressiveGroup("Original concept") { + item { + GitHubUserContainer(devoxin) } } - item { - ExpressiveGroup("Breadboard") { - item { - TitleSummary( - modifier = Modifier.fillMaxWidth(), - title = "GitHub", - summary = "Report bugs, request features, or contribute!" - ) { openUrl(context, "https://github.com/breadboardapp/breadboard") } - } - item { - TitleSummary( - modifier = Modifier.fillMaxWidth(), - title = "Third-party notices", - summary = "Libraries used in Breadboard", - trailingIcon = { ChevronRight() } - ) { navController.navigate(LibrariesSettings) } + + LazyExpressiveGroup("Breadboard") { + item { + TitleSummary( + title = "What's new" + ) { + WhatsNewState.show() } } } + + LazyExpressiveGroup { + item { + TitleSummary( + modifier = Modifier.fillMaxWidth(), + title = "GitHub", + summary = "Report bugs, request features, or contribute!" + ) { openUrl(context, "https://github.com/breadboardapp/breadboard") } + } + item { + TitleSummary( + modifier = Modifier.fillMaxWidth(), + title = "Third-party notices", + summary = "Libraries used in Breadboard", + trailingIcon = { ChevronRight() } + ) { navController.navigate(LibrariesSettings) } + } + } } } } 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 ed6ad743..a20a6718 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/Pref.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/Pref.kt @@ -49,6 +49,7 @@ 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.WhatsNewState import moe.apex.breadboard.util.availableRatingsForSource import moe.apex.breadboard.util.decodeHtml import moe.apex.breadboard.util.replaceGelbooruSubdomain @@ -592,6 +593,12 @@ class UserPreferencesRepository(private val dataStore: DataStore) { } } + /* We're going to show a What's New sheet on major updates, starting with 3.3.0. + Adding a pref for it adds unnecessary complexity, but controlling it here makes sense. */ + if (lastUsedVersionCode < 330) { + WhatsNewState.show() + } + /* Always clear the internal ignored list on updates. */ if (BuildConfig.VERSION_CODE != lastUsedVersionCode) { dataStore.edit { prefs -> diff --git a/app/src/main/java/moe/apex/breadboard/util/WhatsNew.kt b/app/src/main/java/moe/apex/breadboard/util/WhatsNew.kt new file mode 100644 index 00000000..040a75ae --- /dev/null +++ b/app/src/main/java/moe/apex/breadboard/util/WhatsNew.kt @@ -0,0 +1,188 @@ +package moe.apex.breadboard.util + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SheetState +import androidx.compose.material3.SheetValue +import androidx.compose.material3.Text +import androidx.compose.material3.rememberBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import moe.apex.breadboard.BuildConfig + + +private data class WhatsNewEntry( + val title: String, + val summary: String, + // val imageRes: Int? = null, + // val additionalContent: (LazyListScope.() -> Unit)? = null +) + + +private val followingFeed = WhatsNewEntry( + title = "Following feed", + summary = "With Breadboard 3.3 you can now follow your favourite artists!\n\n" + + "Your following feed lives in the Browse tab and is the perfect place to keep " + + "up with your favourite artists' work." +) + + +private val artistProfiles = WhatsNewEntry( + title = "Artist profiles", + summary = "Breadboard's new artist profiles are a beautiful and convenient way to " + + "learn about the creators behind your favourite art.\n\n" + + "An artist's profile shows their other names, social and support links, " + + "and their most popular posts, plus lets you follow them and share their link.\n\n" + + "Tap an artist's tag in the info sheet to visit their profile!" +) + + +private val sauceNao = WhatsNewEntry( + title = "SauceNAO integration", + summary = "Breadboard now supports reverse image search with SauceNAO.\n\n" + + "Got an image saved that you don't know the artist for? " + + "Just upload it on Breadboard's new SauceNAO tab and find out everything " + + "you need to know!" +) + + +private val generalImprovements = WhatsNewEntry( + title = "And more", + summary = "Improved Settings organisation, a fresh font, bug fixes, and more." +) + + +private val whatsNewEntries = listOf(followingFeed, artistProfiles, sauceNao, generalImprovements) + + +object WhatsNewState { + private var _visible = MutableStateFlow(false) + val visible = _visible.asStateFlow() + + fun show() = _visible.update { true } + fun hide() = _visible.update { false } +} + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun WhatsNewBottomSheet(state: SheetState, onDismissRequest: () -> Unit) { + val scope = rememberCoroutineScope() + + TitledModalBottomSheet( + title = "What's new", + sheetState = state, + onDismissRequest = onDismissRequest + ) { + Box { + LazyColumn( + contentPadding = PaddingValues( + start = MEDIUM_SPACER.dp, + end = MEDIUM_SPACER.dp, + bottom = navBarHeight + MEDIUM_SPACER.dp + 48.dp + SMALL_LARGE_SPACER.dp + ), + modifier = Modifier.fillMaxWidth() + ) { + item { + MediumEmphasisCenteredLabel( + text = "Breadboard has been updated to version ${BuildConfig.VERSION_NAME}." + ) + } + + for (entry in whatsNewEntries) { + LazyExpressiveGroup { + item { + Column( + verticalArrangement = Arrangement.spacedBy(SMALL_SPACER.dp), + modifier = Modifier.padding(SMALL_LARGE_SPACER.dp) + ) { + Text( + text = entry.title, + style = MaterialTheme.typography.titleLarge, + fontSize = 20.sp, + maxLines = 1, + color = MaterialTheme.colorScheme.primary + ) + Summary(text = entry.summary) + } + } + } + } + } + Button( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding( + bottom = navBarHeight + MEDIUM_SPACER.dp, + start = SMALL_LARGE_SPACER.dp, + end = SMALL_LARGE_SPACER.dp + ) + .fillMaxWidth(), + onClick = { + scope.launch { + state.hide() + }.invokeOnCompletion { + onDismissRequest() + } + }, + shapes = ButtonDefaults.shapes() + ) { + Text("Let's go!") + } + } + } +} + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WhatsNew() { + val stateVisible by WhatsNewState.visible.collectAsStateWithLifecycle() + val sheetState = rememberBottomSheetState( + initialValue = SheetValue.Hidden, + enabledValues = setOf(SheetValue.Hidden, SheetValue.Expanded) + ) + + /* The migration can set stateVisible to true before composition has even started, + which results in the sheet being visible by default and therefore not having an animation. + This extra layer avoids that by ensuring its only set after composition. */ + var visible by remember { mutableStateOf(false) } + + SideEffect(stateVisible) { + visible = stateVisible + } + + if (visible) { + LaunchedEffect(Unit) { + sheetState.expand() + } + + WhatsNewBottomSheet(sheetState) { + WhatsNewState.hide() + } + } +} From d21eac66a838365edb31210f0313a547e68bfc91 Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Tue, 11 Aug 2026 04:02:20 +0100 Subject: [PATCH 36/59] Update version --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index c7d34750..a62cf8c3 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -20,8 +20,8 @@ android { applicationId "moe.apex.breadboard" minSdk 26 targetSdk 36 - versionCode 323 - versionName "3.2.3" + versionCode 330 + versionName "3.3.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From 6531aef660ec5af16f78ce6b1536ee347de0581f Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Tue, 11 Aug 2026 05:50:25 +0100 Subject: [PATCH 37/59] update privacy policy --- PRIVACY.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/PRIVACY.md b/PRIVACY.md index 59526f95..f8424a79 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,6 +1,6 @@ # Privacy Policy for Breadboard -**Last Updated:** 30 July 2025 +**Last Updated:** 11 August 2026 This Privacy Policy describes how Breadboard ("we," "us," or "our") handles your information when you use our mobile application (the "App"). @@ -9,7 +9,6 @@ This Privacy Policy describes how Breadboard ("we," "us," or "our") handles your We want to be clear: Breadboard is designed with your privacy in mind. - **No Sale of Personal Data:** We do not sell, rent, or trade any of your personal data to third parties. -- **No Analytics or Usage Stats:** We do not collect analytics or usage statistics about how you interact with the App. ### Information We Process and Why @@ -37,6 +36,17 @@ To improve your user experience and allow you to quickly revisit previous search - **User Control and Deletion:** You have full control over your search history. You can delete individual search history entries at any time within the App. You also have the option to disable the search history feature entirely through the App's settings, which will prevent any future search queries from being saved. If you disable search history, any previously saved search history will also be cleared. - **Data Storage:** Your search history is stored only on your device. It is not transmitted to us or any third party. +### Reverse Image Search + +Breadboard provides the ability to perform a 'reverse image search', whereby the user submits an image and the App provides insight such as the artist and original source. +Reverse image search functionality is provided by [SauceNAO](https://saucenao.com/legal.html) and you should consult the SauceNAO privacy policy to learn about how your data and submitted images are handled. + +### Artist Sharing + +Breadboard provides the ability to share an artist profile with other users. This is in the form of a URL that points to a Breadboard-owned domain. +These URLs use industry-standard encryption. Access logs containing the path, requesting IP address, and user agent are kept for 14 days for the purpose of abuse prevention. +Additionally, web requests to Breadboard's domain are routed through [Cloudflare](https://www.cloudflare.com/privacypolicy/) and as such, you should consult their privacy policy. + ### Data Export Breadboard provides you with the functionality to export your application data to a file. This allows you to back up or transfer your information. @@ -49,7 +59,7 @@ Breadboard provides you with the functionality to export your application data t We take reasonable measures to help protect the information we process. When you provide API credentials, they are stored locally on your device, the App's developers do not have access to these credentials, and they are only used for the purpose of making requests to the external platforms you choose to interact with. -As Breadboard does not store any personal data off-device, we do not have a process for you to request deletion of personal data. You can, however, delete any stored API credentials through the App, or clear the App's data through your device settings, which will remove all locally stored information. +As Breadboard does not identify individual users or store data for reasons other than those that are technically necessary, we do not have a process for you to request deletion of personal data. You can, however, delete any stored API credentials through the App, or clear the App's data through your device settings, which will remove all locally stored information. ### Third-Party Services From 4393aa640cac50613ca39bc27388dfa2556f36d2 Mon Sep 17 00:00:00 2001 From: Flaze <51487578+flazepe@users.noreply.github.com> Date: Mon, 17 Aug 2026 02:58:42 +0800 Subject: [PATCH 38/59] Add pulse effect to icons on navigation item select (#61) * Add pulse effect to icons on navigation item select * Refactor code --- .../apex/breadboard/navigation/Navigation.kt | 36 ++++++++++++++++--- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/moe/apex/breadboard/navigation/Navigation.kt b/app/src/main/java/moe/apex/breadboard/navigation/Navigation.kt index 1a70e670..8a1128a0 100644 --- a/app/src/main/java/moe/apex/breadboard/navigation/Navigation.kt +++ b/app/src/main/java/moe/apex/breadboard/navigation/Navigation.kt @@ -1,6 +1,7 @@ package moe.apex.breadboard.navigation import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.CubicBezierEasing import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn @@ -21,6 +22,7 @@ import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -28,6 +30,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection @@ -138,7 +141,8 @@ fun Navigation(navController: NavHostController, startDestination: Any = Search) } else { R.drawable.ic_home_hollow }), - contentDescription = "Browse" + contentDescription = "Browse", + modifier = Modifier.pulseOnSelect(currentRoute.routeIs(homeScreens)) ) }, onClick = { @@ -157,7 +161,8 @@ fun Navigation(navController: NavHostController, startDestination: Any = Search) icon = { Icon( imageVector = Icons.Rounded.Search, - contentDescription = "Search" + contentDescription = "Search", + modifier = Modifier.pulseOnSelect(currentRoute.routeIs(searchScreens)) ) }, onClick = { @@ -185,7 +190,8 @@ fun Navigation(navController: NavHostController, startDestination: Any = Search) R.drawable.ic_image_search_hollow } ), - contentDescription = "SauceNAO Reverse image search" + contentDescription = "SauceNAO Reverse image search", + modifier = Modifier.pulseOnSelect(currentRoute.routeIs(reverseSearchScreens)) ) }, onClick = { @@ -202,7 +208,8 @@ fun Navigation(navController: NavHostController, startDestination: Any = Search) icon = { Icon( imageVector = if (currentRoute.routeIs(Favourites::class)) Icons.Rounded.Favorite else Icons.Rounded.FavoriteBorder, - contentDescription = "Favourite images" + contentDescription = "Favourite images", + modifier = Modifier.pulseOnSelect(currentRoute.routeIs(Favourites::class)) ) }, onClick = { @@ -219,7 +226,8 @@ fun Navigation(navController: NavHostController, startDestination: Any = Search) icon = { Icon( painter = if (currentRoute.routeIs(settingsScreens)) rememberVectorPainter(Icons.Rounded.Settings) else painterResource(R.drawable.ic_settings_hollow), - contentDescription = "Settings" + contentDescription = "Settings", + modifier = Modifier.pulseOnSelect(currentRoute.routeIs(settingsScreens)) ) }, onClick = { @@ -298,3 +306,21 @@ fun Navigation(navController: NavHostController, startDestination: Any = Search) } } } + + +@Composable +private fun Modifier.pulseOnSelect(selected: Boolean): Modifier { + val scale = remember { Animatable(1f) } + + LaunchedEffect(selected) { + if (selected) { + scale.animateTo(1.1f, tween(200)) + scale.animateTo(1f, tween(300)) + } + } + + return this.graphicsLayer { + scaleX = scale.value + scaleY = scale.value + } +} From 315dc6fd0365e79e03fa2f3e2570b19eca0165fb Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Tue, 18 Aug 2026 03:47:01 +0100 Subject: [PATCH 39/59] Move following to breadboard-controlled api --- .../moe/apex/breadboard/home/HomeScreen.kt | 1 - .../moe/apex/breadboard/image/ImageBoard.kt | 19 ++++++++++ .../apex/breadboard/util/FollowingProvider.kt | 38 ++++++------------- 3 files changed, 30 insertions(+), 28 deletions(-) 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 4b20da0d..4b609f2c 100644 --- a/app/src/main/java/moe/apex/breadboard/home/HomeScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/home/HomeScreen.kt @@ -264,7 +264,6 @@ fun HomeScreen( SideEffect(Unit) { val newProvider = FollowingProvider( followedArtists = prefs.followedTags, - auth = prefs.authFor(ImageSource.DANBOORU, context), showAllRatings = prefs.recommendAllRatings, initialBlockedTags = prefs.blockedTags ) 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 7c116b90..881557bb 100644 --- a/app/src/main/java/moe/apex/breadboard/image/ImageBoard.kt +++ b/app/src/main/java/moe/apex/breadboard/image/ImageBoard.kt @@ -424,6 +424,25 @@ object Danbooru : ImageBoard { private val artistSearchUrl = "${baseUrl}artists.json?only=name,urls,other_names,tag&search[any_name_matches]=%s" + suspend fun loadFollowingPage(artists: List, page: Int, safe: Boolean): List { + val baseUrl = "https://breadboard.moe/api/v1/following/posts" + val artistsQuery = artists.joinToString(",") { URLEncoder.encode(it, "utf-8") } + val url = "$baseUrl?artists=$artistsQuery&page=$page&safe=$safe" + + val body = RequestUtil.get(url) + if (body.isEmpty()) return emptyList() + + val json = JSONArray(body) + val images = mutableListOf() + + for (i in 0 until json.length()) { + val e = json.getJSONObject(i) + parseImage(e)?.let { images.add(it) } + } + + return images + } + override fun parseImage(e: JSONObject): Image? { if (e.isNull("md5")) return null diff --git a/app/src/main/java/moe/apex/breadboard/util/FollowingProvider.kt b/app/src/main/java/moe/apex/breadboard/util/FollowingProvider.kt index 966f5818..095827d1 100644 --- a/app/src/main/java/moe/apex/breadboard/util/FollowingProvider.kt +++ b/app/src/main/java/moe/apex/breadboard/util/FollowingProvider.kt @@ -9,19 +9,15 @@ import androidx.compose.runtime.mutableStateSetOf import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.Snapshot import moe.apex.breadboard.image.Danbooru -import moe.apex.breadboard.image.DanbooruSafe import moe.apex.breadboard.image.Image -import moe.apex.breadboard.image.ImageBoardAuth import moe.apex.breadboard.viewmodel.GridStateHolder import moe.apex.breadboard.viewmodel.GridStateHolderDelegate class FollowingProvider( followedArtists: Set, - private val auth: ImageBoardAuth?, private val showAllRatings: Boolean, private val initialBlockedTags: Set, ) : GridStateHolder by GridStateHolderDelegate() { - private val source = if (showAllRatings) Danbooru else DanbooruSafe private val mutableFollowedArtists = mutableStateSetOf().apply { addAll(followedArtists) } val followedArtists: Set get() = mutableFollowedArtists.toSet() @@ -62,40 +58,28 @@ class FollowingProvider( try { isLoading = true - Log.d("FollowingProvider", "Using source: ${source.baseUrl} (showAllRatings: $showAllRatings)") - val tagLimit = if (auth == null) 2 else 12 + Log.d("FollowingProvider", "Using Breadboard proxy (showAllRatings: $showAllRatings)") - val batches = followedArtists.toList().chunked(tagLimit) - val allResults = mutableListOf() + val results = Danbooru.loadFollowingPage( + artists = followedArtists.toList(), + page = pageNumber, + safe = !showAllRatings, + ) - for (batch in batches) { - val searchQuery = batch.joinToString(" ") { "~$it" } - val results = source.loadPage( - tags = searchQuery, - page = pageNumber, - auth = auth - ) - allResults.addAll( - elements = results.filter { - it.metadata!!.tags.none { tag -> blockedTags.contains(tag.lowercase()) } - } - ) + val filteredResults = results.filter { + it.metadata!!.tags.none { tag -> blockedTags.contains(tag.lowercase()) } } - // Deduplicate and sort by ID descending - val sortedResults = allResults.distinctBy { it.id } - .sortedByDescending { it.id?.toLongOrNull() ?: 0L } - - if (sortedResults.isEmpty()) { + if (results.isEmpty()) { shouldKeepSearching = false } else { if (pageNumber == Danbooru.firstPageIndex) { Snapshot.withMutableSnapshot { images.clear() - images.addAll(sortedResults) + images.addAll(filteredResults) } } else { - val newImages = sortedResults.filter { newImg -> images.none { it.id == newImg.id } } + val newImages = filteredResults.filter { newImg -> images.none { it.id == newImg.id } } images += newImages } pageNumber++ From 4c2d42e2c02cd58087e1d0fff2f74804354fb7bd Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Tue, 18 Aug 2026 03:50:52 +0100 Subject: [PATCH 40/59] update compose and m3 --- 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 ef1ba210..52a04f59 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.06.01" -material3 = "1.5.0-alpha25" +compose-bom = "2026.08.00" +material3 = "1.5.0-alpha26" documentfile = "1.1.0" navigation-compose = "2.9.8" paging-compose = "3.5.0" From 7d106765b1ff172a3e2c2cb9b7152ae00fcc4d4b Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Tue, 18 Aug 2026 04:44:55 +0100 Subject: [PATCH 41/59] Update privacy policy --- PRIVACY.md | 72 +++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 52 insertions(+), 20 deletions(-) diff --git a/PRIVACY.md b/PRIVACY.md index f8424a79..8ca81840 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,36 +1,67 @@ # Privacy Policy for Breadboard -**Last Updated:** 11 August 2026 +**Last Updated:** 18 August 2026 This Privacy Policy describes how Breadboard ("we," "us," or "our") handles your information when you use our mobile application (the "App"). -### Information We Do Not Collect +Breadboard owns the domain names breadboard.moe and fxweibo.com. Web requests to these domains use industry-standard encryption and are routed through [Cloudflare](https://www.cloudflare.com/privacypolicy/) and as such, you should consult their privacy policy in addition to ours. -We want to be clear: Breadboard is designed with your privacy in mind. +### Introduction -- **No Sale of Personal Data:** We do not sell, rent, or trade any of your personal data to third parties. +We want to be clear: the Breadboard app is designed with your privacy in mind. + +**No Sale of Personal Data:** We do not sell, rent, or trade any of your personal data to third parties. ### Information We Process and Why -The core purpose of Breadboard is to help you retrieve data from external platforms. To facilitate this, the following information is used: +The core purpose of the App is to help you retrieve data from external platforms, but we also provide our own services to help facilitate this. +We will split this section into two parts for ease of understanding. + +#### Information Shared by The App +- **IP Address and Web Access Logs:** + - When accessing domains owned by us, access logs containing your IP address, URL accessed, and your User-Agent will be kept for 14 days for abuse and fraud prevention. + - In some cases, this may include user-created data such as a list of 'tags' in the URL to retrieve a curated feed. -- **IP Address for External Requests:** When you use Breadboard to fetch data from an external platform, your device's IP address will be used to make these requests. This is a standard part of how the internet works. We do not store your IP address. You should consult the privacy policy of the specific external platforms you interact with through Breadboard to understand how they handle your IP address and any other data they may collect. -- **Optional API Credentials:** Breadboard allows you to optionally provide API credentials (such as API keys or tokens) for specific external platforms to enable enhanced functionality. - - **Purpose and Sharing:** These credentials will be stored on your device and will **only** be shared with the specific external platform they are intended for. These credentials will be used solely for the purpose of authenticating your requests to that platform and enabling the enhanced functionality on the platform. +- **Optional API Credentials:** The App allows you to optionally provide API credentials (such as API keys or tokens) for specific external platforms to enable enhanced functionality. + - **Purpose and Sharing:** These credentials will be stored on your device and will **only** be shared with the specific external platform they are intended for. These credentials will be used solely for the purpose of authenticating your requests to that platform and enabling enhanced functionality on the platform. - **User Control:** You are in control of providing these credentials and can revoke them at any time through the App's settings. +#### Information Processed by External Platforms +- **IP Address:** + - When you use the App to fetch data from an external platform, your device's IP address will be used to make these requests. +- **Other User-created Data:** + - This may include search queries or aggregated data derived from your App data. Read below to learn more. + +You should consult the privacy policy of the specific external platforms you interact with through the App to understand how they handle your IP address and any other data they may collect. + +### Following Feed + +The App offers the ability to 'Follow' artists. + +- **How it Works:** Each artist is represented by a unique tag that is used to retrieve their posts. The user is able to save a list of these tags locally that can be used to build a curated feed of posts. +- **Data Sent:** When requesting or refreshing your Following feed, this list may be sent to a domain owned by us and used to retrieve relevant posts. +- **Purpose:** The sole purpose of sending this list is to retrieve a list of posts by the requested artists using their tags. +- **User Control:** You can choose not to use this functionality by not clicking the 'Follow' button on an artist profile. + +When a user has an empty list of followed artists, the App will use posts the user has marked as a 'Favourite' to recommend artists to follow. +This takes place entirely on-device. + +If the user chooses to follow one or more artists from the recommendations that the App provides, the resultant list of followed artists may be sent to a domain owned by us. + +In some cases, some or all parts of this list may be sent to [Danbooru](https://danbooru.donmai.us/privacy) to retrieve the relevant posts by these artists. Refer to Danbooru's privacy policy to learn how they use your data. + ### Post Recommendations -To enhance your experience, Breadboard offers a feature to recommend posts based on content you have previously added to your favourites within the App. +To enhance your experience, the App offers a feature to recommend posts based on content you have previously added to your favourites within the App. -- **How it Works:** When you use this feature, Breadboard sends aggregated metadata derived from your favourited posts as a search query to the relevant external platform. For example, if you have favourited several posts with specific tags or keywords, Breadboard might use these common tags or keywords to find similar content on that platform. +- **How it Works:** When you use this feature, the App sends aggregated metadata derived from your favourited posts as a search query to the relevant external platform. For example, if you have favourited several posts with specific tags or keywords, the App can use these common tags or keywords to find similar content on that platform. - **Data Sent:** Only the aggregated metadata (like common tags, categories, or keywords) is sent to the external platform as a search query. We do not send your specific list of favourited posts or any personally identifiable information from your device to the external platform for this purpose. - **Purpose:** The sole purpose of sending this aggregated metadata is to retrieve recommendations for similar posts from the external platform. - **User Control:** You can choose not to use this functionality by not visiting the "Browse" page within the App. You can set the App's "Start page" setting to something other than "Browse" to prevent this feature from automatically working when you open the App. ### Search History -To improve your user experience and allow you to quickly revisit previous searches, Breadboard stores your search queries locally on your device. +To improve your user experience and allow you to quickly revisit previous searches, the App stores your search queries locally on your device. - **How it Works:** When you perform a search within the App, the search terms you use are saved to your device's local storage. - **User Control and Deletion:** You have full control over your search history. You can delete individual search history entries at any time within the App. You also have the option to disable the search history feature entirely through the App's settings, which will prevent any future search queries from being saved. If you disable search history, any previously saved search history will also be cleared. @@ -38,32 +69,33 @@ To improve your user experience and allow you to quickly revisit previous search ### Reverse Image Search -Breadboard provides the ability to perform a 'reverse image search', whereby the user submits an image and the App provides insight such as the artist and original source. +The App provides the ability to perform a 'reverse image search', whereby the user submits an image and the App provides insight such as the artist and original source. Reverse image search functionality is provided by [SauceNAO](https://saucenao.com/legal.html) and you should consult the SauceNAO privacy policy to learn about how your data and submitted images are handled. ### Artist Sharing -Breadboard provides the ability to share an artist profile with other users. This is in the form of a URL that points to a Breadboard-owned domain. -These URLs use industry-standard encryption. Access logs containing the path, requesting IP address, and user agent are kept for 14 days for the purpose of abuse prevention. -Additionally, web requests to Breadboard's domain are routed through [Cloudflare](https://www.cloudflare.com/privacypolicy/) and as such, you should consult their privacy policy. +The App provides the ability to share an artist profile with other users. This is in the form of a URL that points to the breadboard.moe domain. +Refer to this document's section about Information Shared by The App to learn more about how your data is used. ### Data Export -Breadboard provides you with the functionality to export your application data to a file. This allows you to back up or transfer your information. +The App provides you with the functionality to export your application data to a file. This allows you to back up or transfer your information. - **What is Exported:** The user has the ability to choose which data categories are exported. The categories available for export include: **App settings**, **Favourite images**, and **Search history**. - **Sensitive Information Exclusion:** For your security, sensitive information such as API credentials you have saved within the App will **not** be included in the exported file. -- **User Responsibility:** Once you export your data to a file, that file is stored on your device or in a location you choose. The security and management of this exported file become your responsibility. Breadboard is not responsible for the exported file once it has been created and saved outside of the App. +- **User Responsibility:** Once you export your data to a file, that file is stored on your device or in a location you choose. The security and management of this exported file become your responsibility. We are not responsible for the exported file once it has been created and saved outside of the App. ### Data Security We take reasonable measures to help protect the information we process. When you provide API credentials, they are stored locally on your device, the App's developers do not have access to these credentials, and they are only used for the purpose of making requests to the external platforms you choose to interact with. -As Breadboard does not identify individual users or store data for reasons other than those that are technically necessary, we do not have a process for you to request deletion of personal data. You can, however, delete any stored API credentials through the App, or clear the App's data through your device settings, which will remove all locally stored information. +As the App does not identify individual users or store data for reasons other than those that are technically necessary, we do not have a process for you to request deletion of personal data. You can, however, delete any stored API credentials through the App, or clear the App's data through your device settings, which will remove all locally stored information. + +Web access logs are deleted after 14 days. ### Third-Party Services -As mentioned, Breadboard interacts with external platforms. This Privacy Policy does not apply to the practices of third parties that we do not own or control. We encourage you to review the privacy policies of any third-party services you access through Breadboard. +As mentioned, the App interacts with external platforms. This Privacy Policy does not apply to the practices of third parties that we do not own or control. We encourage you to review the privacy policies of any third-party services you access through the App. ### Changes to This Privacy Policy @@ -71,7 +103,7 @@ We may update this Privacy Policy from time to time. You are advised to review t ### Your Consent -By using the Breadboard app, you consent to our Privacy Policy. +By using the App, you consent to our Privacy Policy. ### Contact Us From f86f3f3f6a0de201d97728c27b2e1ef577fee8c7 Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Tue, 18 Aug 2026 04:48:19 +0100 Subject: [PATCH 42/59] Update version --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index a62cf8c3..b54db39a 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -20,8 +20,8 @@ android { applicationId "moe.apex.breadboard" minSdk 26 targetSdk 36 - versionCode 330 - versionName "3.3.0" + versionCode 331 + versionName "3.3.1" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From 0c2ee2d026903a996e086c5bfe386bdc5142a6db Mon Sep 17 00:00:00 2001 From: Flaze <51487578+flazepe@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:48:02 +0800 Subject: [PATCH 43/59] Add manual dark theme preference support (#62) * Add manual dark theme preference support * Fix typos * Simplify function --- app/src/main/AndroidManifest.xml | 9 +++- .../java/moe/apex/breadboard/MainActivity.kt | 21 ++++++++ .../breadboard/home/FollowedArtistsScreen.kt | 4 +- .../preferences/LayoutSettingsScreen.kt | 22 +++++++- .../moe/apex/breadboard/preferences/Pref.kt | 27 ++++++++++ .../breadboard/preferences/Preferences.kt | 4 +- .../moe/apex/breadboard/ui/theme/Theme.kt | 53 ++++++++++++++++++- 7 files changed, 132 insertions(+), 8 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6b98fc7f..7aa0cfc8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -18,7 +18,11 @@ android:launchMode="singleTop" android:taskAffinity="moe.apex.breadboard.MainActivity" android:exported="true" - android:theme="@style/Theme.Breadboard"> + android:theme="@style/Theme.Breadboard" + android:configChanges="uiMode"> @@ -29,7 +33,8 @@ android:taskAffinity="moe.apex.breadboard.DeepLinkActivity" android:launchMode="singleTop" android:exported="true" - android:theme="@style/Theme.Breadboard"> + android:theme="@style/Theme.Breadboard" + android:configChanges="uiMode"> diff --git a/app/src/main/java/moe/apex/breadboard/MainActivity.kt b/app/src/main/java/moe/apex/breadboard/MainActivity.kt index b87d0ef7..b65564f2 100644 --- a/app/src/main/java/moe/apex/breadboard/MainActivity.kt +++ b/app/src/main/java/moe/apex/breadboard/MainActivity.kt @@ -1,5 +1,6 @@ package moe.apex.breadboard +import android.app.UiModeManager import android.content.Context import android.content.Intent import android.os.Build @@ -35,6 +36,7 @@ import moe.apex.breadboard.navigation.Home import moe.apex.breadboard.navigation.Navigation import moe.apex.breadboard.navigation.Results import moe.apex.breadboard.navigation.Search +import moe.apex.breadboard.preferences.DarkTheme import moe.apex.breadboard.preferences.ImageSource import moe.apex.breadboard.preferences.LocalPreferences import moe.apex.breadboard.preferences.StartDestination @@ -123,6 +125,25 @@ class MainActivity : SingletonImageLoader.Factory, ComponentActivity(), VolumeBu val viewModel = getGlobalViewModel() val recommendationsProvider by viewModel.recommendationsProvider.collectAsState() + /* Sync the UiModeManager's app night mode preference with the selected app dark theme + preference. This ensures that the splash screen colour scheme matches the selected app + dark theme preference. + + UiModeManager#setApplicationNightMode() is only supported on Android 12+. */ + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val uiModeManager = getSystemService(UI_MODE_SERVICE) as UiModeManager + + SideEffect(prefs.darkTheme) { + uiModeManager.setApplicationNightMode( + when (prefs.darkTheme) { + DarkTheme.ON -> UiModeManager.MODE_NIGHT_YES + DarkTheme.OFF -> UiModeManager.MODE_NIGHT_NO + DarkTheme.AUTO -> UiModeManager.MODE_NIGHT_AUTO + } + ) + } + } + SideEffect(prefs.imageSource, prefs.filterRatingsLocally) { if ( recommendationsProvider?.imageSource != prefs.imageSource || diff --git a/app/src/main/java/moe/apex/breadboard/home/FollowedArtistsScreen.kt b/app/src/main/java/moe/apex/breadboard/home/FollowedArtistsScreen.kt index 2574b5bb..709bb823 100644 --- a/app/src/main/java/moe/apex/breadboard/home/FollowedArtistsScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/home/FollowedArtistsScreen.kt @@ -1,7 +1,6 @@ package moe.apex.breadboard.home import androidx.compose.foundation.background -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -42,6 +41,7 @@ import moe.apex.breadboard.preferences.ImageSource import moe.apex.breadboard.preferences.LocalPreferences import moe.apex.breadboard.preferences.PreferenceKeys import moe.apex.breadboard.prefs +import moe.apex.breadboard.ui.theme.shouldUseDarkTheme import moe.apex.breadboard.util.DISABLED_OPACITY import moe.apex.breadboard.util.ExpressiveTagEntryContainer import moe.apex.breadboard.util.LargeTitleBar @@ -65,7 +65,7 @@ fun FollowedArtistsScreen(navController: NavHostController) { val userPreferencesRepository = LocalContext.current.prefs val prefs = LocalPreferences.current val followedTags = prefs.followedTags.toList().sorted() - val darkTheme = isSystemInDarkTheme() + val darkTheme = shouldUseDarkTheme() MainScreenScaffold( topAppBar = { diff --git a/app/src/main/java/moe/apex/breadboard/preferences/LayoutSettingsScreen.kt b/app/src/main/java/moe/apex/breadboard/preferences/LayoutSettingsScreen.kt index 90aa3c72..3f607408 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/LayoutSettingsScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/LayoutSettingsScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController import kotlinx.coroutines.launch import moe.apex.breadboard.prefs +import moe.apex.breadboard.ui.theme.doesSystemSupportDarkTheme import moe.apex.breadboard.util.LargeTitleBar import moe.apex.breadboard.util.LazyExpressiveGroup import moe.apex.breadboard.util.MainScreenScaffold @@ -119,7 +120,26 @@ fun LayoutSettingsScreen(navController: NavHostController) { } } - LazyExpressiveGroup("Layout") { + LazyExpressiveGroup("Appearance and Layout") { + item { + EnumPref( + title = "Dark theme", + summary = currentSettings.darkTheme.label, + enumItems = DarkTheme.entries.filter { entry -> + // Hide the auto option if system dark theme is not supported + !(entry == DarkTheme.AUTO && !doesSystemSupportDarkTheme()) + }, + selectedItem = currentSettings.darkTheme, + onSelection = { + scope.launch { + preferencesRepository.updatePref( + PreferenceKeys.DARK_THEME, + it + ) + } + } + ) + } item { ReorderablePref( title = "Reorder image actions", 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 a20a6718..cddc0303 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/Pref.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/Pref.kt @@ -45,6 +45,7 @@ import moe.apex.breadboard.image.Safebooru import moe.apex.breadboard.image.Yandere import moe.apex.breadboard.image.AI_TAG_NAMES import moe.apex.breadboard.tag.TagCategory +import moe.apex.breadboard.ui.theme.doesSystemSupportDarkTheme import moe.apex.breadboard.util.AgeVerification import moe.apex.breadboard.util.MigrationOnlyField import moe.apex.breadboard.util.PixivArtwork @@ -98,6 +99,7 @@ data object PrefNames { const val INTERNAL_IGNORE_LIST = "internal_ignore_list" const val AUTOPLAY_VIDEOS = "autoplay_videos" const val UNIFIED_INFO_SHEET = "unified_info_sheet" + const val DARK_THEME = "dark_theme" const val FOLLOWED_TAGS = "followed_tags" const val DEFAULT_BROWSE_TAB = "default_browse_tab" const val PROFILES_FOR_ALL_TAGS = "profiles_for_all_tags" @@ -136,6 +138,7 @@ object PreferenceKeys { val INTERNAL_IGNORE_LIST = stringSetPreferencesKey(PrefNames.INTERNAL_IGNORE_LIST) val AUTOPLAY_VIDEOS = stringPreferencesKey(PrefNames.AUTOPLAY_VIDEOS) val UNIFIED_INFO_SHEET = booleanPreferencesKey(PrefNames.UNIFIED_INFO_SHEET) + val DARK_THEME = stringPreferencesKey(PrefNames.DARK_THEME) val FOLLOWED_TAGS = stringSetPreferencesKey(PrefNames.FOLLOWED_TAGS) val DEFAULT_BROWSE_TAB = stringPreferencesKey(PrefNames.DEFAULT_BROWSE_TAB) val PROFILES_FOR_ALL_TAGS = booleanPreferencesKey(PrefNames.PROFILES_FOR_ALL_TAGS) @@ -243,6 +246,7 @@ data class Prefs( val internalIgnoreList: Set, val autoplayVideos: AutoplayVideosMode, val unifiedInfoSheet: Boolean, + val darkTheme: DarkTheme, val followedTags: Set, val defaultBrowseTab: BrowseTab, val profilesForAllTags: Boolean, @@ -280,6 +284,7 @@ data class Prefs( internalIgnoreList = emptySet(), autoplayVideos = AutoplayVideosMode.OFF, unifiedInfoSheet = false, // Unified is called 'Classic' in the UI + darkTheme = DarkTheme.AUTO, followedTags = emptySet(), defaultBrowseTab = BrowseTab.FOR_YOU, profilesForAllTags = false, @@ -350,6 +355,7 @@ class UserPreferencesRepository(private val dataStore: DataStore) { 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), + PreferenceKeys.DARK_THEME to PrefMeta(PrefCategory.SETTING), PreferenceKeys.FOLLOWED_TAGS to PrefMeta(PrefCategory.SETTING, mergeable = true), PreferenceKeys.DEFAULT_BROWSE_TAB to PrefMeta(PrefCategory.SETTING), PreferenceKeys.PROFILES_FOR_ALL_TAGS to PrefMeta(PrefCategory.SETTING), @@ -393,6 +399,18 @@ class UserPreferencesRepository(private val dataStore: DataStore) { val currentPreferences = dataStore.data.first() val lastUsedVersionCode = currentPreferences[PreferenceKeys.LAST_USED_VERSION_CODE] ?: 0 + /* Version 3.3.2 introduces the manual dark theme preference. Android 9 had some partial + support for system dark theme, but older versions would have no support whatsoever. + Set the default dark theme preference to a manual OFF state on those versions. */ + val darkTheme = currentPreferences[PreferenceKeys.DARK_THEME] + + if ( + !doesSystemSupportDarkTheme() && + (darkTheme == null || darkTheme == DarkTheme.AUTO.name) + ) { + updatePref(PreferenceKeys.DARK_THEME, DarkTheme.OFF) + } + /* lastUsedVersionCode can be 0 if the user had it installed already but cleared the data. In such a case, we can't reliably determine what their previous version was. Just load the default settings. */ @@ -858,6 +876,7 @@ class UserPreferencesRepository(private val dataStore: DataStore) { val internalIgnoreList = preferences[PreferenceKeys.INTERNAL_IGNORE_LIST] ?: Prefs.DEFAULT.internalIgnoreList val autoplayVideos = preferences[PreferenceKeys.AUTOPLAY_VIDEOS]?.let { AutoplayVideosMode.valueOf(it) } ?: Prefs.DEFAULT.autoplayVideos val unifiedInfoSheet = preferences[PreferenceKeys.UNIFIED_INFO_SHEET] ?: Prefs.DEFAULT.unifiedInfoSheet + val darkTheme = preferences[PreferenceKeys.DARK_THEME]?.let { DarkTheme.valueOf(it) } ?: Prefs.DEFAULT.darkTheme val followedTags = preferences[PreferenceKeys.FOLLOWED_TAGS] ?: Prefs.DEFAULT.followedTags val defaultBrowseTab = preferences[PreferenceKeys.DEFAULT_BROWSE_TAB]?.let { BrowseTab.valueOf(it) } ?: Prefs.DEFAULT.defaultBrowseTab val profilesForAllTags = preferences[PreferenceKeys.PROFILES_FOR_ALL_TAGS] ?: Prefs.DEFAULT.profilesForAllTags @@ -894,6 +913,7 @@ class UserPreferencesRepository(private val dataStore: DataStore) { internalIgnoreList, autoplayVideos, unifiedInfoSheet, + darkTheme, followedTags, defaultBrowseTab, profilesForAllTags, @@ -913,3 +933,10 @@ enum class ImageSource(override val label: String, val imageBoard: ImageBoard) : YANDERE("Yande.re", Yandere), R34("Rule34", Rule34) } + + +enum class DarkTheme(override val label: String) : PrefEnum { + ON("Always"), + OFF("Never"), + AUTO("Follow system") +} 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 c12d8990..fc0010d4 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/Preferences.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/Preferences.kt @@ -1,7 +1,6 @@ package moe.apex.breadboard.preferences import androidx.compose.foundation.background -import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize @@ -47,6 +46,7 @@ import moe.apex.breadboard.navigation.DataSettings import moe.apex.breadboard.navigation.ExperimentalSettings import moe.apex.breadboard.navigation.GeneralSettings import moe.apex.breadboard.navigation.LayoutSettings +import moe.apex.breadboard.ui.theme.shouldUseDarkTheme import moe.apex.breadboard.util.ChevronRight import moe.apex.breadboard.util.LazyExpressiveGroup import moe.apex.breadboard.util.MainScreenScaffold @@ -194,7 +194,7 @@ private fun ContainedIcon( label: String, imageVector: ImageVector ) { - val darkTheme = isSystemInDarkTheme() + val darkTheme = shouldUseDarkTheme() val colourPair = remember(label) { generateColours(darkTheme, label) } diff --git a/app/src/main/java/moe/apex/breadboard/ui/theme/Theme.kt b/app/src/main/java/moe/apex/breadboard/ui/theme/Theme.kt index b63b8418..da5e672e 100644 --- a/app/src/main/java/moe/apex/breadboard/ui/theme/Theme.kt +++ b/app/src/main/java/moe/apex/breadboard/ui/theme/Theme.kt @@ -1,6 +1,10 @@ package moe.apex.breadboard.ui.theme import android.os.Build +import androidx.activity.ComponentActivity +import androidx.activity.SystemBarStyle +import androidx.activity.compose.LocalActivity +import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.MaterialExpressiveTheme @@ -11,9 +15,12 @@ import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.Immutable +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import moe.apex.breadboard.preferences.DarkTheme +import moe.apex.breadboard.preferences.LocalPreferences private val DarkColorScheme = darkColorScheme( @@ -54,14 +61,58 @@ val LocalBreadboardColors = staticCompositionLocalOf { } +@Composable +fun shouldUseDarkTheme(): Boolean { + val preferences = LocalPreferences.current + + return when (preferences.darkTheme) { + DarkTheme.ON -> true + DarkTheme.OFF -> false + DarkTheme.AUTO -> isSystemInDarkTheme() + } +} + + +fun doesSystemSupportDarkTheme(): Boolean { + /* Android 9 had some partial support for system dark theme, + but older versions would have no support whatsoever. */ + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.P +} + + @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun BreadboardTheme( - darkTheme: Boolean = isSystemInDarkTheme(), + darkTheme: Boolean = shouldUseDarkTheme(), // Dynamic color is available on Android 12+ dynamicColor: Boolean = true, content: @Composable () -> Unit ) { + /* Ensure that the status bar and navigation bar colour scheme match the current app dark theme + preference. This is necessary on Android 11 and lower if the app dark theme preference + is the opposite of the system dark theme preference. + + This is not necessary for Android 12+ since we already sync the UiModeManager's app night mode + preference to match the app dark theme preference, so the system changes the style accordingly. */ + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + val context = LocalActivity.current as ComponentActivity + + SideEffect(darkTheme) { + val systemBarStyle = if (darkTheme) { + SystemBarStyle.dark( + scrim = android.graphics.Color.TRANSPARENT + ) + } else { + SystemBarStyle.light( + scrim = android.graphics.Color.TRANSPARENT, + darkScrim = android.graphics.Color.TRANSPARENT + ) + } + + context.enableEdgeToEdge(statusBarStyle = systemBarStyle, navigationBarStyle = systemBarStyle) + } + } + var colorScheme = when { dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { val context = LocalContext.current From 6459f55c8b99d8338a29dd55067d11aeafaa5891 Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Mon, 24 Aug 2026 03:22:43 +0100 Subject: [PATCH 44/59] Improve back flow when tapping tabs --- .../apex/breadboard/navigation/Navigation.kt | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/moe/apex/breadboard/navigation/Navigation.kt b/app/src/main/java/moe/apex/breadboard/navigation/Navigation.kt index 8a1128a0..ab90787a 100644 --- a/app/src/main/java/moe/apex/breadboard/navigation/Navigation.kt +++ b/app/src/main/java/moe/apex/breadboard/navigation/Navigation.kt @@ -146,12 +146,18 @@ fun Navigation(navController: NavHostController, startDestination: Any = Search) ) }, onClick = { - if (!currentRoute.routeIs(homeScreens)) { + /* The 2nd clause is currently always true, + but might not be in the future. + Handling that now because I'll probably forget otherwise. */ + if ( + currentRoute.routeIs(FollowedArtists::class) && + navController.previousBackStackEntry?.destination.routeIs(Home::class) + ) { + navController.popBackStack() + } else if (!currentRoute.routeIs(Home::class)) { navController.navigate(Home) { popUpTo(Home) { inclusive = true } } - } else { - navController.popBackStack() } } ) @@ -231,7 +237,14 @@ fun Navigation(navController: NavHostController, startDestination: Any = Search) ) }, onClick = { - if (currentRoute.routeIs(settingsScreens.filter { it != Settings::class })) { + /* The 2nd clause might be false if the user clicks a button that + takes them to a specific settings page. + In such cases, tapping the settings tab should take them to the + settings home page, not back to where they were before. */ + if ( + currentRoute.routeIs(settingsScreens.filter { it != Settings::class }) && + navController.previousBackStackEntry?.destination.routeIs(settingsScreens) + ) { navController.popBackStack() } else if (!currentRoute.routeIs(Settings::class)) { navController.navigate(Settings) { From 858cae02f362dd86526bab53827082a97ec1e539 Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Mon, 24 Aug 2026 03:29:11 +0100 Subject: [PATCH 45/59] remove useless comment from testing saucenao --- .../java/moe/apex/breadboard/saucenao/SauceNaoResultsScreen.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/java/moe/apex/breadboard/saucenao/SauceNaoResultsScreen.kt b/app/src/main/java/moe/apex/breadboard/saucenao/SauceNaoResultsScreen.kt index 20484194..812dd7f2 100644 --- a/app/src/main/java/moe/apex/breadboard/saucenao/SauceNaoResultsScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/saucenao/SauceNaoResultsScreen.kt @@ -118,7 +118,6 @@ fun SauceNaoResultsScreen( var selectedImages by remember { mutableStateOf(viewableImages) } val state by viewModel.resultsState.collectAsState() - // For inline image viewing via OffsetBasedLargeImageView var isImageViewerActive by remember { mutableStateOf(false) } fun onViewInBreadboard(vararg ids: Int?) { From dd0d9f22d47f2df85af1d982277a5161566d10eb Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Mon, 24 Aug 2026 03:40:15 +0100 Subject: [PATCH 46/59] don't remove metadata from yandere images in saucenao --- .../moe/apex/breadboard/viewmodel/SaucenaoResultsViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/moe/apex/breadboard/viewmodel/SaucenaoResultsViewModel.kt b/app/src/main/java/moe/apex/breadboard/viewmodel/SaucenaoResultsViewModel.kt index 1537c62d..e86956d8 100644 --- a/app/src/main/java/moe/apex/breadboard/viewmodel/SaucenaoResultsViewModel.kt +++ b/app/src/main/java/moe/apex/breadboard/viewmodel/SaucenaoResultsViewModel.kt @@ -89,7 +89,7 @@ class SaucenaoResultsViewModel: ViewModel() { _viewableImages.value += image } else { val meta = it.source.imageBoard.loadImageGroupedTags(image, auth) - _viewableImages.value += image.copy(metadata = meta) + _viewableImages.value += meta?.let { image.copy(metadata = it) } ?: image } } } catch (e: Exception) { From 4d035380ee15fa41aa8db458cc358bda846314bb Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Mon, 24 Aug 2026 05:52:41 +0100 Subject: [PATCH 47/59] refactor image metadata refetching to be more intentional --- .../breadboard/artist/ArtistProfileScreen.kt | 4 +- .../breadboard/detailview/SearchResults.kt | 9 +++- .../apex/breadboard/favourites/Favourites.kt | 10 ++++- .../moe/apex/breadboard/home/HomeScreen.kt | 28 ++++++++---- .../java/moe/apex/breadboard/image/Image.kt | 40 +++++++++-------- .../largeimageview/LargeImageView.kt | 43 ++++++++----------- .../apex/breadboard/util/FollowingProvider.kt | 20 +++++---- .../moe/apex/breadboard/util/ImageUtils.kt | 30 +++++++++++++ .../apex/breadboard/util/Recommendations.kt | 23 +++++++--- .../viewmodel/ArtistProfileViewModel.kt | 9 ---- .../viewmodel/SaucenaoResultsViewModel.kt | 10 ++++- 11 files changed, 140 insertions(+), 86 deletions(-) create mode 100644 app/src/main/java/moe/apex/breadboard/util/ImageUtils.kt diff --git a/app/src/main/java/moe/apex/breadboard/artist/ArtistProfileScreen.kt b/app/src/main/java/moe/apex/breadboard/artist/ArtistProfileScreen.kt index a99e6d6c..8dd9692a 100644 --- a/app/src/main/java/moe/apex/breadboard/artist/ArtistProfileScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/artist/ArtistProfileScreen.kt @@ -231,15 +231,13 @@ fun ArtistProfileScreen( } } + // Danbooru always provides grouped tags, the main use for onImageUpdate, so we don't need it. OffsetBasedLargeImageView( navController = navController, isActive = shouldShowLargeImage, initialSelectedImageIndex = selectedImageIndex, allImages = images, onActiveStateChanged = { shouldShowLargeImage = it }, - onImageUpdate = { oldImage, newImage -> - viewModel.updateImage(oldImage, newImage) - } ) } 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 1db5796d..a0107427 100644 --- a/app/src/main/java/moe/apex/breadboard/detailview/SearchResults.kt +++ b/app/src/main/java/moe/apex/breadboard/detailview/SearchResults.kt @@ -47,6 +47,7 @@ import moe.apex.breadboard.util.ScrollToTopArrow import moe.apex.breadboard.util.TINY_SPACER import moe.apex.breadboard.util.availableRatingsForCurrentSource import moe.apex.breadboard.util.filterChipSolidColor +import moe.apex.breadboard.util.refreshImageMetadata import moe.apex.breadboard.util.rememberPullToRefreshController import moe.apex.breadboard.viewmodel.SearchResultsViewModel @@ -242,7 +243,11 @@ fun SearchResults(navController: NavController, source: ImageSource, tagList: Li initialSelectedImageIndex = selectedImageIndex, allImages = imagesToDisplay, onActiveStateChanged = { isImageCarouselVisible = it } - ) { oldImage, newImage -> - viewModel.updateImage(oldImage, newImage) + ) { image -> + if (!image.hasGroupedTags) { + refreshImageMetadata(image, prefs.authFor(image.imageSource, context)) { + viewModel.updateImage(image, it) + } + } } } 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 ca572e0d..12a20558 100644 --- a/app/src/main/java/moe/apex/breadboard/favourites/Favourites.kt +++ b/app/src/main/java/moe/apex/breadboard/favourites/Favourites.kt @@ -38,6 +38,7 @@ import moe.apex.breadboard.util.ScrollToTopArrow import moe.apex.breadboard.util.TINY_SPACER import moe.apex.breadboard.util.bottomAppBarAndNavBarHeight import moe.apex.breadboard.util.filterChipSolidColor +import moe.apex.breadboard.util.refreshImageMetadata import moe.apex.breadboard.util.onScroll import moe.apex.breadboard.viewmodel.FavouritesViewModel @@ -49,6 +50,7 @@ fun FavouritesPage( viewModel: FavouritesViewModel = viewModel(), navBarVisibilityCallback: (Boolean) -> Unit = { } ) { + val context = LocalContext.current val prefs = LocalPreferences.current val preferencesRepository = LocalContext.current.prefs val topAppBarState = rememberTopAppBarState() @@ -188,7 +190,11 @@ fun FavouritesPage( isImageCarouselVisible = it navBarVisibilityCallback(!it) } - ) { oldImage, newImage -> - preferencesRepository.updateFavouriteImage(oldImage, newImage) + ) { image -> + refreshImageMetadata(image, prefs.authFor(image.imageSource, context)) { newImage -> + scope.launch { + preferencesRepository.updateFavouriteImage(image, newImage) + } + } } } 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 4b609f2c..4b7b9880 100644 --- a/app/src/main/java/moe/apex/breadboard/home/HomeScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/home/HomeScreen.kt @@ -98,10 +98,12 @@ import moe.apex.breadboard.util.TINY_SPACER import moe.apex.breadboard.util.bottomAppBarAndNavBarHeight import moe.apex.breadboard.util.differenceOlderThan import moe.apex.breadboard.util.filterChipSolidColor +import moe.apex.breadboard.util.refreshImageMetadata import moe.apex.breadboard.util.onScroll import moe.apex.breadboard.util.rememberPullToRefreshController import moe.apex.breadboard.util.saveIgnoreListWithTimestamp import moe.apex.breadboard.viewmodel.getGlobalViewModel +import kotlin.collections.emptyList import kotlin.time.Duration.Companion.days @@ -116,8 +118,13 @@ fun HomeScreen( val prefs = LocalPreferences.current val viewModel = getGlobalViewModel() val defaultTab by viewModel.defaultBrowseTab.collectAsState() + val recommendationsProvider by viewModel.recommendationsProvider.collectAsState() + val recommendedImages = recommendationsProvider?.recommendedImages?.collectAsState()?.value ?: emptyList() + val followingProvider by viewModel.followingProvider.collectAsState() + val followingImages = followingProvider?.images?.collectAsState()?.value ?: emptyList() + val blockedTags by rememberUpdatedState(prefs.blockedTags) val unfollowedTags by rememberUpdatedState(prefs.unfollowedTags) val builtInIgnoredTags by rememberUpdatedState(prefs.internalIgnoreList) @@ -366,7 +373,7 @@ fun HomeScreen( navBarVisibilityCallback(!it.lastScrolledForward) }, userScrollEnabled = !shouldShowLargeImage, - images = provider.recommendedImages, + images = recommendedImages, onImageClick = { index, _ -> Snapshot.withMutableSnapshot { selectedImageIndex = index @@ -423,7 +430,7 @@ fun HomeScreen( } val ptrController = rememberPullToRefreshController( - enabled = provider.images.isNotEmpty() || + enabled = followingImages.isNotEmpty() || (provider.doneInitialLoad && prefs.followedTags.isNotEmpty()), onRefresh = onRefresh ) @@ -436,7 +443,7 @@ fun HomeScreen( navBarVisibilityCallback(!it.lastScrolledForward) }, userScrollEnabled = !shouldShowLargeImage, - images = provider.images, + images = followingImages, onImageClick = { index, _ -> Snapshot.withMutableSnapshot { selectedImageIndex = index @@ -520,21 +527,24 @@ fun HomeScreen( } } - val recommendedImages = if (pagerState.currentPage == 0) recommendationsProvider?.recommendedImages else followingProvider?.images + val displayImages = if (pagerState.currentPage == 0) recommendedImages else followingImages OffsetBasedLargeImageView( navController = navController, isActive = shouldShowLargeImage, initialSelectedImageIndex = selectedImageIndex, - allImages = recommendedImages ?: emptyList(), + allImages = displayImages, onActiveStateChanged = { shouldShowLargeImage = it navBarVisibilityCallback(!it) } - ) { oldImage, newImage -> - if (recommendedImages != null) { - val index = recommendedImages.indexOf(oldImage) - if (index != -1) recommendedImages[index] = newImage + ) { image -> + // Following feed is Danbooru which always has grouped tags so not needed for that. + if (pagerState.currentPage == 0 && !image.hasGroupedTags) { + refreshImageMetadata(image, prefs.authFor(image.imageSource, context)) { newImage -> + val index = recommendedImages.indexOf(image) + recommendationsProvider?.updateImage(index, newImage) + } } } } 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 a9a9a879..70b15d14 100644 --- a/app/src/main/java/moe/apex/breadboard/image/Image.kt +++ b/app/src/main/java/moe/apex/breadboard/image/Image.kt @@ -67,27 +67,29 @@ data class Image( val highestQualityFormatUrl = fileUrl.takeIf { it.isNotEmpty() } ?: sampleUrl val isVideo = fileFormat == "mp4" || fileFormat == "webm" - val hasGroupedTags: Boolean - get() { - /* 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. + val hasGroupedTags: Boolean by lazy { + /* 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@lazy false + } - Otherwise, the least we could do is make sure that the only existing group is a non-general one. - This does not usually happen, but we would at least know that it is already grouped in this case. + /* Usually, we can tell that the existing grouped tags are grouped properly if they have more than one group. - The images that pass would usually have ungrouped tags, but it could also catch actual images that - literally only have general tags. They're usually due to bad tagging and should be re-fetched anyway. */ - if ( - metadata.groupedTags.size > 1 || - (metadata.groupedTags.size == 1 && metadata.groupedTags[0].category != TagCategory.GENERAL) - ) - return true + Otherwise, the least we could do is make sure that the only existing group is a non-general one. + This does not usually happen, but we would at least know that it is already grouped in this case. - return false + The images that pass would usually have ungrouped tags, but it could also catch actual images that + literally only have general tags. They're usually due to bad tagging and should be re-fetched anyway. */ + if ( + metadata.groupedTags.size > 1 || + (metadata.groupedTags.size == 1 && metadata.groupedTags[0].category != TagCategory.GENERAL) + ) { + return@lazy true } + + return@lazy false + } } \ No newline at end of file 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 005b16a7..046e25ca 100644 --- a/app/src/main/java/moe/apex/breadboard/largeimageview/LargeImageView.kt +++ b/app/src/main/java/moe/apex/breadboard/largeimageview/LargeImageView.kt @@ -166,6 +166,7 @@ import moe.apex.breadboard.util.downloadImage import moe.apex.breadboard.util.downloadImageToClipboard import moe.apex.breadboard.util.fixLink import moe.apex.breadboard.util.isWebLink +import moe.apex.breadboard.util.refreshImageMetadata import moe.apex.breadboard.util.rememberIsBlurEnabled import moe.apex.breadboard.util.saveUriToPref import moe.apex.breadboard.util.morphingBackground @@ -202,7 +203,7 @@ private fun LargeImageView( navController: NavController, initialSelectedImageIndex: Int, allImages: List, - onImageUpdate: (suspend (Image, Image) -> Unit)? = null, + onImageUpdate: (suspend (Image) -> Unit)? = null, onZoomedStatusChanged: ((Boolean) -> Unit)? = null ) { val pagerState = rememberPagerState( @@ -223,10 +224,6 @@ private fun LargeImageView( derivedStateOf { activeZoomState?.zoomFraction?.let { it < MAX_ZOOM_FOR_PAGE_CHANGE } ?: true } } - val context = LocalContext.current - val prefs = LocalPreferences.current - val scope = rememberCoroutineScope() - LaunchedEffect(allImages.size) { if (pagerState.currentPage >= allImages.size && allImages.isNotEmpty()) { pagerState.scrollToPage(allImages.size - 1) @@ -234,23 +231,11 @@ private fun LargeImageView( } val currentImage = allImages[pagerState.currentPage.coerceIn(0, allImages.size - 1)] - val hasGroupedTags = remember(currentImage) { currentImage.hasGroupedTags } - if (!hasGroupedTags && onImageUpdate != null) { + if (onImageUpdate != null) { LaunchedEffect(currentImage) { try { - val metadata = currentImage.imageSource.imageBoard.loadImageGroupedTags( - currentImage, - prefs.authFor(currentImage.imageSource, context) - ) - - if (metadata != null) { - val newImage = currentImage.copy(metadata = metadata) - - scope.launch { - onImageUpdate(currentImage, newImage) - } - } + onImageUpdate(currentImage) } catch (e: CancellationException) { // Ignore the CancellationException error above because we want it to be cancelled } catch (e: Exception) { @@ -665,17 +650,27 @@ fun LazyLargeImageView( isLoading = false } - if (isLoading) + if (isLoading) { FullscreenLoadingSpinner() - else if (image == null) + } else if (image == null) { ImageNotFound() - else + } else { LargeImageView( navController, 0, listOf(image!!), - onImageUpdate = { _, newImage -> image = newImage } + onImageUpdate = { + if (image?.hasGroupedTags == false) { + refreshImageMetadata( + image = image!!, + auth = prefs.authFor(image!!.imageSource, context) + ) { newImage -> + image = newImage + } + } + } ) + } } @@ -1189,7 +1184,7 @@ fun OffsetBasedLargeImageView( initialSelectedImageIndex: Int, allImages: List, onActiveStateChanged: (Boolean) -> Unit = { }, - onImageUpdate: (suspend (Image, Image) -> Unit)? = null, + onImageUpdate: (suspend (Image) -> Unit)? = null, ) { val scope = rememberCoroutineScope() val density = LocalDensity.current diff --git a/app/src/main/java/moe/apex/breadboard/util/FollowingProvider.kt b/app/src/main/java/moe/apex/breadboard/util/FollowingProvider.kt index 095827d1..9c8e5462 100644 --- a/app/src/main/java/moe/apex/breadboard/util/FollowingProvider.kt +++ b/app/src/main/java/moe/apex/breadboard/util/FollowingProvider.kt @@ -3,11 +3,13 @@ package moe.apex.breadboard.util 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.mutableStateSetOf import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.Snapshot +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import moe.apex.breadboard.image.Danbooru import moe.apex.breadboard.image.Image import moe.apex.breadboard.viewmodel.GridStateHolder @@ -40,7 +42,9 @@ class FollowingProvider( } } - var images = mutableStateListOf() + private val _images = MutableStateFlow(listOf()) + val images = _images.asStateFlow() + var doneInitialLoad by mutableStateOf(false) private var pageNumber by mutableIntStateOf(Danbooru.firstPageIndex) @@ -50,7 +54,7 @@ class FollowingProvider( suspend fun loadMore() { if (followedArtists.isEmpty()) { doneInitialLoad = true - return images.clear() + return _images.update { emptyList() } } if (isLoading || !shouldKeepSearching) { return @@ -74,13 +78,11 @@ class FollowingProvider( shouldKeepSearching = false } else { if (pageNumber == Danbooru.firstPageIndex) { - Snapshot.withMutableSnapshot { - images.clear() - images.addAll(filteredResults) - } + _images.update { filteredResults } } else { - val newImages = filteredResults.filter { newImg -> images.none { it.id == newImg.id } } - images += newImages + _images.update { current -> + current + filteredResults.filter { it !in current } + } } pageNumber++ } diff --git a/app/src/main/java/moe/apex/breadboard/util/ImageUtils.kt b/app/src/main/java/moe/apex/breadboard/util/ImageUtils.kt new file mode 100644 index 00000000..b7cd9776 --- /dev/null +++ b/app/src/main/java/moe/apex/breadboard/util/ImageUtils.kt @@ -0,0 +1,30 @@ +package moe.apex.breadboard.util + +import moe.apex.breadboard.image.Image +import moe.apex.breadboard.image.ImageBoardAuth + + +/** + * Refresh the metadata for an image, particularly useful for updating tag categories. + * + * This will make a request to the image board for the metadata and will merge it with the provided + * [image] and pass the final [Image] object into [onImageUpdate]. + * + * If the new metadata is `null` or is identical to the metadata already on [image], + * [onImageUpdate] will **not** be called. + * + * @return A [Boolean] representing whether or not the image was updated. + */ +suspend fun refreshImageMetadata( + image: Image, + auth: ImageBoardAuth?, + onImageUpdate: (Image) -> Unit +): Boolean { + val metadata = image.imageSource.imageBoard.loadImageGroupedTags(image, auth) + + return if (metadata != null && metadata != image.metadata) { + val newImage = image.copy(metadata = metadata) + onImageUpdate(newImage) + true + } else false +} 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 a2168df8..fb4283bd 100644 --- a/app/src/main/java/moe/apex/breadboard/util/Recommendations.kt +++ b/app/src/main/java/moe/apex/breadboard/util/Recommendations.kt @@ -3,11 +3,13 @@ package moe.apex.breadboard.util 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.mutableStateSetOf import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.Snapshot +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import moe.apex.breadboard.image.Image import moe.apex.breadboard.image.ImageBoardAuth import moe.apex.breadboard.image.ImageBoardRequirement @@ -32,7 +34,9 @@ class RecommendationsProvider( private const val SELECTION_SIZE_DANBOORU = 2 } - val recommendedImages = mutableStateListOf() + private val _recommendedImages = MutableStateFlow(listOf()) + val recommendedImages = _recommendedImages.asStateFlow() + var doneInitialLoad by mutableStateOf(false) val recommendedTags = mutableListOf() private var pageNumber by mutableIntStateOf(imageSource.imageBoard.firstPageIndex) @@ -61,6 +65,12 @@ class RecommendationsProvider( } } + fun updateImage(index: Int, newImage: Image) { + _recommendedImages.update { + it.toMutableList().apply { this[index] = newImage } + } + } + fun prepareRecommendedTags() { recommendedTags.clear() shouldKeepSearching = true @@ -141,12 +151,11 @@ class RecommendationsProvider( shouldKeepSearching = false } else { if (pageNumber == imageSource.imageBoard.firstPageIndex) { - Snapshot.withMutableSnapshot { - recommendedImages.clear() - recommendedImages.addAll(wantedResults) - } + _recommendedImages.update { wantedResults } } else if (wantedResults.isNotEmpty()) { - recommendedImages.addAll(wantedResults.filter { it !in recommendedImages }) + _recommendedImages.update { current -> + current + wantedResults.filter { it !in current } + } } } pageNumber++ diff --git a/app/src/main/java/moe/apex/breadboard/viewmodel/ArtistProfileViewModel.kt b/app/src/main/java/moe/apex/breadboard/viewmodel/ArtistProfileViewModel.kt index fdc53e89..a8b7e7db 100644 --- a/app/src/main/java/moe/apex/breadboard/viewmodel/ArtistProfileViewModel.kt +++ b/app/src/main/java/moe/apex/breadboard/viewmodel/ArtistProfileViewModel.kt @@ -32,13 +32,4 @@ class ArtistProfileViewModel(private val artistTag: String): ViewModel() { _isInitialised.value = 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 - } - } } diff --git a/app/src/main/java/moe/apex/breadboard/viewmodel/SaucenaoResultsViewModel.kt b/app/src/main/java/moe/apex/breadboard/viewmodel/SaucenaoResultsViewModel.kt index e86956d8..0640eaf4 100644 --- a/app/src/main/java/moe/apex/breadboard/viewmodel/SaucenaoResultsViewModel.kt +++ b/app/src/main/java/moe/apex/breadboard/viewmodel/SaucenaoResultsViewModel.kt @@ -4,6 +4,7 @@ import android.util.Log import androidx.lifecycle.ViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import moe.apex.breadboard.image.Image import moe.apex.breadboard.image.ImageBoardAuth import moe.apex.breadboard.preferences.ImageSource @@ -13,6 +14,7 @@ import moe.apex.breadboard.saucenao.SauceNaoResponse import moe.apex.breadboard.saucenao.SauceNaoResponseHeader import moe.apex.breadboard.saucenao.SauceNaoResultGroup import moe.apex.breadboard.saucenao.groupResults +import moe.apex.breadboard.util.refreshImageMetadata sealed class ResultsState { @@ -88,8 +90,12 @@ class SaucenaoResultsViewModel: ViewModel() { if (image.hasGroupedTags) { _viewableImages.value += image } else { - val meta = it.source.imageBoard.loadImageGroupedTags(image, auth) - _viewableImages.value += meta?.let { image.copy(metadata = it) } ?: image + val refreshed = refreshImageMetadata(image, auth) { newImage -> + _viewableImages.update { it + newImage } + } + if (!refreshed) { + _viewableImages.update { it + image } + } } } } catch (e: Exception) { From cb61489718739d17bc01983ff1fb1862fe936427 Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Mon, 24 Aug 2026 05:54:16 +0100 Subject: [PATCH 48/59] Update version --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index b54db39a..2ed5ce3b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -20,8 +20,8 @@ android { applicationId "moe.apex.breadboard" minSdk 26 targetSdk 36 - versionCode 331 - versionName "3.3.1" + versionCode 332 + versionName "3.3.2" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From 6a88f6d9d1118fbc05006faca4b339de5a22904e Mon Sep 17 00:00:00 2001 From: Flaze <51487578+flazepe@users.noreply.github.com> Date: Wed, 26 Aug 2026 03:12:49 +0800 Subject: [PATCH 49/59] Support sharing images to reverse search (#63) * Support sharing images to reverse search * Support image clipboard paste for reverse search * Rename `newIntent` back to `intent` * Figured it's not that big of a deal for it to be ambiguous here * Reduce redundant reverse search intent code * Remove unused import * Better and descriptive approach * Reset selected file URI on paste button click * Minor changes * Fix app stacking on older Android versions * Move comments to the top (can't make comments between attributes, blame XML) * Create a copy of the intent instead * Probably not a good idea to just mutate any intent we provide to the method * Less code * Move back stuff * I just don't like seeing messy diffs :p * Improve comment * Set reverse search intent label to SauceNao * I think this is better than Reverse Search since it's shorter (it doesn't get truncated) and matches the screen name in the app * Fix capitalization --- app/src/main/AndroidManifest.xml | 25 +++++++++----- .../java/moe/apex/breadboard/MainActivity.kt | 14 ++++++++ .../breadboard/navigation/Destinations.kt | 2 +- .../apex/breadboard/navigation/Navigation.kt | 9 +++-- .../saucenao/ReverseSearchScreen.kt | 33 ++++++++++++++----- 5 files changed, 63 insertions(+), 20 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7aa0cfc8..8aeb80a7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,12 @@ + + android:enableOnBackInvokedCallback="true" + android:configChanges="uiMode" + android:documentLaunchMode="intoExisting"> + android:theme="@style/Theme.Breadboard"> + + + + + + + android:theme="@style/Theme.Breadboard"> diff --git a/app/src/main/java/moe/apex/breadboard/MainActivity.kt b/app/src/main/java/moe/apex/breadboard/MainActivity.kt index b65564f2..313d2f60 100644 --- a/app/src/main/java/moe/apex/breadboard/MainActivity.kt +++ b/app/src/main/java/moe/apex/breadboard/MainActivity.kt @@ -35,6 +35,7 @@ import moe.apex.breadboard.navigation.Favourites import moe.apex.breadboard.navigation.Home import moe.apex.breadboard.navigation.Navigation import moe.apex.breadboard.navigation.Results +import moe.apex.breadboard.navigation.ReverseSearch import moe.apex.breadboard.navigation.Search import moe.apex.breadboard.preferences.DarkTheme import moe.apex.breadboard.preferences.ImageSource @@ -90,6 +91,19 @@ class MainActivity : SingletonImageLoader.Factory, ComponentActivity(), VolumeBu private fun determineDestination(intent: Intent): Any? { + // Handle Intent.ACTION_SEND intent for reverse search + if (intent.action == Intent.ACTION_SEND) { + /* Sometimes a shared link may contain an image clipData of the favicon, so we must + prioritise the text over the clipData. Providing them both would be bad. */ + intent.extras?.getString(Intent.EXTRA_TEXT)?.let { initialImageUrl -> + return ReverseSearch(initialImageUrl = initialImageUrl) + } + + intent.clipData?.getItemAt(0)?.uri?.let { initialFileUri -> + return ReverseSearch(initialFileUri = initialFileUri.toString()) + } + } + return when (intent.getStringExtra("destination")) { "artist" -> maybePrepareArtistDestination(intent) "search" -> maybePrepareResultsDestination(intent) diff --git a/app/src/main/java/moe/apex/breadboard/navigation/Destinations.kt b/app/src/main/java/moe/apex/breadboard/navigation/Destinations.kt index f28e0500..c44a1d53 100644 --- a/app/src/main/java/moe/apex/breadboard/navigation/Destinations.kt +++ b/app/src/main/java/moe/apex/breadboard/navigation/Destinations.kt @@ -88,7 +88,7 @@ object FollowedArtists object Search @Serializable -object ReverseSearch +data class ReverseSearch(val initialImageUrl: String? = null, val initialFileUri: String? = null) @Serializable data class Results( diff --git a/app/src/main/java/moe/apex/breadboard/navigation/Navigation.kt b/app/src/main/java/moe/apex/breadboard/navigation/Navigation.kt index ab90787a..00214a6f 100644 --- a/app/src/main/java/moe/apex/breadboard/navigation/Navigation.kt +++ b/app/src/main/java/moe/apex/breadboard/navigation/Navigation.kt @@ -202,8 +202,8 @@ fun Navigation(navController: NavHostController, startDestination: Any = Search) }, onClick = { if (!currentRoute.routeIs(ReverseSearch::class)) { - navController.navigate(ReverseSearch) { - popUpTo(ReverseSearch) { inclusive = true } + navController.navigate(ReverseSearch()) { + popUpTo(ReverseSearch()) { inclusive = true } } } } @@ -288,7 +288,10 @@ fun Navigation(navController: NavHostController, startDestination: Any = Search) } composable { HomeScreen(navController) { isNavigationBarVisible = it } } composable { SearchScreen(navController, focusRequester) } - composable { ReverseSearchScreen(navController) } + composable { + val args = it.toRoute() + ReverseSearchScreen(navController, args.initialImageUrl, args.initialFileUri) + } composable { val args = it.toRoute() SearchResults(navController, args.source, args.tags) diff --git a/app/src/main/java/moe/apex/breadboard/saucenao/ReverseSearchScreen.kt b/app/src/main/java/moe/apex/breadboard/saucenao/ReverseSearchScreen.kt index 4e4f51d7..2392a64d 100644 --- a/app/src/main/java/moe/apex/breadboard/saucenao/ReverseSearchScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/saucenao/ReverseSearchScreen.kt @@ -80,14 +80,18 @@ import moe.apex.breadboard.util.showToast @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable -fun ReverseSearchScreen(navController: NavController) { +fun ReverseSearchScreen( + navController: NavController, + initialImageUrl: String? = null, + initialFileUri: String? = null +) { val context = LocalContext.current val clipboard = LocalClipboard.current val prefs = LocalPreferences.current val scope = rememberCoroutineScope() - var imageUrl by rememberSaveable { mutableStateOf("") } - var selectedFileUri by rememberSaveable { mutableStateOf(null) } + var imageUrl by rememberSaveable { mutableStateOf(initialImageUrl ?: "") } + var selectedFileUri by rememberSaveable { mutableStateOf(initialFileUri?.toUri()) } val imagePickerLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.GetContent() @@ -197,13 +201,26 @@ fun ReverseSearchScreen(navController: NavController) { IconButton( onClick = { scope.launch { - val clipboardText = + val clipboardItem = clipboard.nativeClipboard.primaryClip?.getItemAt( 0 - )?.text?.toString() - if (clipboardText != null) { - imageUrl = clipboardText - selectedFileUri = null + ) + + if (clipboardItem != null) { + val uri = clipboardItem.uri + val mimeType = uri?.let { + context.contentResolver.getType(it) + } + + if (mimeType?.startsWith("image/") == true) { + selectedFileUri = uri + } else { + /* onValueChange does not trigger when you mutate + imageUrl via code, so we still have to reset + selectedFileUri here. */ + selectedFileUri = null + imageUrl = clipboardItem.text.toString() + } } else { showToast(context, "Nothing on the clipboard") } From a2050bbac82b286ca6d57977bd17fdd069c1ab89 Mon Sep 17 00:00:00 2001 From: Flaze <51487578+flazepe@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:03:27 +0800 Subject: [PATCH 50/59] Fix potential crash on bad clipboard item mime type (#64) --- .../saucenao/ReverseSearchScreen.kt | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/moe/apex/breadboard/saucenao/ReverseSearchScreen.kt b/app/src/main/java/moe/apex/breadboard/saucenao/ReverseSearchScreen.kt index 2392a64d..8d86d2cd 100644 --- a/app/src/main/java/moe/apex/breadboard/saucenao/ReverseSearchScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/saucenao/ReverseSearchScreen.kt @@ -205,24 +205,28 @@ fun ReverseSearchScreen( clipboard.nativeClipboard.primaryClip?.getItemAt( 0 ) - - if (clipboardItem != null) { - val uri = clipboardItem.uri - val mimeType = uri?.let { + val mimeType = + clipboardItem?.uri?.let { context.contentResolver.getType(it) } - if (mimeType?.startsWith("image/") == true) { - selectedFileUri = uri - } else { + if (mimeType?.startsWith("image/") == true) { + selectedFileUri = clipboardItem.uri + } else { + val clipboardText = + clipboardItem?.text?.toString()?.trim()?.takeIf { + it.isNotEmpty() + } + + if (clipboardText != null) { /* onValueChange does not trigger when you mutate imageUrl via code, so we still have to reset selectedFileUri here. */ selectedFileUri = null - imageUrl = clipboardItem.text.toString() + imageUrl = clipboardText + } else { + showToast(context, "Nothing on the clipboard") } - } else { - showToast(context, "Nothing on the clipboard") } } } From 78e3110690887bef3f4b930713c7eda1779d711f Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Fri, 28 Aug 2026 03:39:11 +0100 Subject: [PATCH 51/59] Only show profile option for artists and general tags --- .../breadboard/largeimageview/InfoSheet.kt | 37 ++++++++++++++++--- .../preferences/ContentSettingsScreen.kt | 14 +++---- 2 files changed, 38 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 6ac58893..811aeab4 100644 --- a/app/src/main/java/moe/apex/breadboard/largeimageview/InfoSheet.kt +++ b/app/src/main/java/moe/apex/breadboard/largeimageview/InfoSheet.kt @@ -5,6 +5,7 @@ import android.annotation.SuppressLint import android.content.ComponentName import android.content.Context import android.content.Intent +import android.util.Log import androidx.compose.animation.AnimatedContent import androidx.compose.animation.SharedTransitionLayout import androidx.compose.animation.animateColorAsState @@ -36,6 +37,10 @@ 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 +import androidx.compose.material.icons.rounded.Copyright +import androidx.compose.material.icons.rounded.DataObject +import androidx.compose.material.icons.rounded.Person +import androidx.compose.material.icons.rounded.PersonSearch import androidx.compose.material.icons.rounded.Search import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.ButtonDefaults @@ -61,6 +66,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.semantics.selected @@ -115,6 +121,14 @@ private enum class InfoSheetPage { } +private sealed class TagMenuAction(val icon: ImageVector, val label: String) +private object ArtistAction : TagMenuAction(Icons.Rounded.Person, "View profile") +private object CharacterAction : TagMenuAction(Icons.Rounded.PersonSearch, "Search character") +private object CopyrightAction : TagMenuAction(Icons.Rounded.Copyright, "Search copyright") +private object MetaAction : TagMenuAction(Icons.Rounded.DataObject, "Search meta") +private object GeneralAction : TagMenuAction(Icons.Rounded.Search, "Search") + + @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun InfoSheet(navController: NavController, image: Image, onDismissRequest: () -> Unit) { @@ -160,18 +174,18 @@ fun InfoSheet(navController: NavController, image: Image, onDismissRequest: () - var selectedTag: String? by remember { mutableStateOf(null) } var selectedTagCategory: TagCategory? by remember { mutableStateOf(null) } - fun startTagSearch(tag: String, category: TagCategory, artistProfileForNonArtistTags: Boolean = false) { + fun startTagSearch(tag: String, category: TagCategory, artistProfileForUncategorisedTags: Boolean = false) { hideAndThen { /* Don't do new interactions inside the DeepLinkActivity. We should only ever do them inside the main one. */ if (context is DeepLinkActivity) { - val intent = if (category == TagCategory.ARTIST || artistProfileForNonArtistTags) { + val intent = if (category == TagCategory.ARTIST || (category == TagCategory.GENERAL && artistProfileForUncategorisedTags)) { createArtistIntent(context, tag, image.imageSource) } else { createSearchIntent(context, image.imageSource, tag) } context.startActivity(intent) - } else if (category == TagCategory.ARTIST || artistProfileForNonArtistTags) { + } else if (category == TagCategory.ARTIST || (category == TagCategory.GENERAL && artistProfileForUncategorisedTags)) { navController.navigate(ArtistProfile(tag, image.imageSource)) } else { navController.navigate(Results(image.imageSource, listOf(tag))) @@ -215,9 +229,20 @@ fun InfoSheet(navController: NavController, image: Image, onDismissRequest: () - ) Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + val action = when { + selectedTagCategory == TagCategory.ARTIST || (prefs.profilesForAllTags && selectedTagCategory == TagCategory.GENERAL) -> ArtistAction + selectedTagCategory == TagCategory.CHARACTER -> CharacterAction + selectedTagCategory == TagCategory.COPYRIGHT -> CopyrightAction + selectedTagCategory == TagCategory.META -> MetaAction + selectedTagCategory == TagCategory.GENERAL -> GeneralAction + else -> { + Log.w("InfoSheet", "Action not implemented for category: $selectedTagCategory") + GeneralAction + } + } ButtonListItem( - label = if (selectedTagCategory == TagCategory.ARTIST || prefs.profilesForAllTags) "View profile" else "Search", - icon = Icons.Rounded.Search, + label = action.label, + icon = action.icon, modifier = Modifier.fillMaxWidth(), position = ListItemPosition.TOP ) { @@ -229,7 +254,7 @@ fun InfoSheet(navController: NavController, image: Image, onDismissRequest: () - startTagSearch( tag = searchTag, category = category, - artistProfileForNonArtistTags = prefs.profilesForAllTags + artistProfileForUncategorisedTags = prefs.profilesForAllTags ) } } diff --git a/app/src/main/java/moe/apex/breadboard/preferences/ContentSettingsScreen.kt b/app/src/main/java/moe/apex/breadboard/preferences/ContentSettingsScreen.kt index e3177e98..577e1982 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/ContentSettingsScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/ContentSettingsScreen.kt @@ -179,14 +179,14 @@ fun ContentSettingsScreen(navController: NavHostController) { item { SwitchPref( checked = currentSettings.profilesForAllTags, - title = "Allow profiles for all tags", - summary = "Replace the search option in the tag menu with an option to " + - "view its profile.", - infoText = "Not all artists are categorised correctly by the image " + - "boards, meaning the profile option would normally be " + - "unavailable for their tag.\n\n" + + title = "View profile for general tags", + summary = "Replace the search option in the menu for general tags with " + + "an option to view its profile.", + infoText = "Not all artists are categorised by the image boards, " + + "meaning the profile option would normally be unavailable for " + + "their tag.\n\n" + "This option forces the profile option to appear for every " + - "tag, even non-artist ones." + "tag without a dedicated category." ) { scope.launch { preferencesRepository.updatePref( From 1a83765899811672d15a775b92a46149c89388f2 Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Fri, 28 Aug 2026 04:11:43 +0100 Subject: [PATCH 52/59] Get settings ready for release --- .../breadboard/preferences/AboutScreen.kt | 62 ++++++--- .../preferences/DataSettingsScreen.kt | 21 +++ .../preferences/LayoutSettingsScreen.kt | 124 ++++++++---------- .../breadboard/preferences/Preferences.kt | 7 +- 4 files changed, 123 insertions(+), 91 deletions(-) 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 77503354..d6666cb5 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/AboutScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/AboutScreen.kt @@ -2,9 +2,11 @@ package moe.apex.breadboard.preferences import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.offset @@ -34,6 +36,11 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextLinkStyles +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.withLink import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController import coil3.compose.AsyncImage @@ -49,6 +56,7 @@ import moe.apex.breadboard.util.LazyExpressiveGroup import moe.apex.breadboard.util.MainScreenScaffold import moe.apex.breadboard.util.SmallVerticalSpacer import moe.apex.breadboard.util.MEDIUM_SPACER +import moe.apex.breadboard.util.SMALL_SPACER import moe.apex.breadboard.util.TitleSummary import moe.apex.breadboard.util.WhatsNewState import moe.apex.breadboard.util.openUrl @@ -144,20 +152,52 @@ fun AboutScreen(navController: NavHostController) { .offset(y = (-2).dp), ) } + SmallVerticalSpacer() + Text( text = "Breadboard", style = MaterialTheme.typography.titleLarge ) - Text( - text = "${BuildConfig.VERSION_NAME} (${releasePlatform.displayName})", - style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + + Row( + horizontalArrangement = Arrangement.spacedBy(SMALL_SPACER.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "${BuildConfig.VERSION_NAME} (${releasePlatform.displayName})", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = "•", + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = buildAnnotatedString { + withLink( + LinkAnnotation.Clickable( + tag = "What's new", + styles = TextLinkStyles( + style = SpanStyle( + color = MaterialTheme.colorScheme.secondary + ) + ) + ) { + WhatsNewState.show() + } + ) { + append("What's new") + } + }, + style = MaterialTheme.typography.labelLarge + ) + } } } - LazyExpressiveGroup("Maintainer", desiredTopPadding = null) { + LazyExpressiveGroup("Maintainer") { item { GitHubUserContainer(apex2504) } @@ -170,16 +210,6 @@ fun AboutScreen(navController: NavHostController) { } LazyExpressiveGroup("Breadboard") { - item { - TitleSummary( - title = "What's new" - ) { - WhatsNewState.show() - } - } - } - - LazyExpressiveGroup { item { TitleSummary( modifier = Modifier.fillMaxWidth(), diff --git a/app/src/main/java/moe/apex/breadboard/preferences/DataSettingsScreen.kt b/app/src/main/java/moe/apex/breadboard/preferences/DataSettingsScreen.kt index d19dbed3..d5557180 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/DataSettingsScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/DataSettingsScreen.kt @@ -57,6 +57,7 @@ fun DataSettingsScreen(navController: NavHostController) { var storageLocationPromptLaunched by remember { mutableStateOf(false) } val currentSettings = LocalPreferences.current + val preferencesRepository = context.prefs if (storageLocationPromptLaunched) { StorageLocationSelection( @@ -176,6 +177,26 @@ fun DataSettingsScreen(navController: NavHostController) { contentPadding = PaddingValues(MEDIUM_SPACER.dp) ) { LazyExpressiveGroup(desiredTopPadding = null) { + item { + EnumPref( + title = "Data saver", + summary = currentSettings.dataSaver.label, + enumItems = DataSaver.entries, + infoText = "When data saver is enabled, images will load in a " + + "lower resolution by default.\n\n" + + "Downloads will always be in the maximum resolution " + + "regardless of this setting.", + selectedItem = currentSettings.dataSaver, + onSelection = { + scope.launch { + preferencesRepository.updatePref( + PreferenceKeys.DATA_SAVER, + it + ) + } + } + ) + } item { TitleSummary( modifier = Modifier.fillMaxWidth(), diff --git a/app/src/main/java/moe/apex/breadboard/preferences/LayoutSettingsScreen.kt b/app/src/main/java/moe/apex/breadboard/preferences/LayoutSettingsScreen.kt index 3f607408..a4e19e48 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/LayoutSettingsScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/LayoutSettingsScreen.kt @@ -48,79 +48,9 @@ fun LayoutSettingsScreen(navController: NavHostController) { contentPadding = PaddingValues(MEDIUM_SPACER.dp), ) { LazyExpressiveGroup( - title = "Behaviour", + title = "Appearance and layout", desiredTopPadding = null ) { - item { - EnumPref( - title = "Start page", - summary = currentSettings.defaultStartDestination.label, - enumItems = StartDestination.entries, - selectedItem = currentSettings.defaultStartDestination, - ) { - scope.launch { - preferencesRepository.updatePref( - PreferenceKeys.DEFAULT_START_DESTINATION, - it - ) - } - } - } - /* It's an enum so EnumPref would be the "correct" implementation, - but I think this is a better UX as there are currently only two options. */ - item { - SwitchPref( - checked = currentSettings.defaultBrowseTab == BrowseTab.FOLLOWING, - title = "Prefer Following tab", - summary = "By default, open the Following tab instead of the For You " + - "tab on the Browse page.", - ) { following -> - scope.launch { - preferencesRepository.updatePref( - PreferenceKeys.DEFAULT_BROWSE_TAB, - if (following) BrowseTab.FOLLOWING else BrowseTab.FOR_YOU - ) - } - } - } - item { - EnumPref( - title = "Hide app content", - summary = currentSettings.flagSecureMode.label, - enumItems = FlagSecureMode.entries, - selectedItem = currentSettings.flagSecureMode - ) { - scope.launch { - preferencesRepository.updatePref( - PreferenceKeys.FLAG_SECURE_MODE, - it - ) - } - } - } - item { - EnumPref( - title = "Data saver", - summary = currentSettings.dataSaver.label, - enumItems = DataSaver.entries, - infoText = "When data saver is enabled, images will load in a " + - "lower resolution by default.\n\n" + - "Downloads will always be in the maximum resolution " + - "regardless of this setting.", - selectedItem = currentSettings.dataSaver, - onSelection = { - scope.launch { - preferencesRepository.updatePref( - PreferenceKeys.DATA_SAVER, - it - ) - } - } - ) - } - } - - LazyExpressiveGroup("Appearance and Layout") { item { EnumPref( title = "Dark theme", @@ -145,7 +75,7 @@ fun LayoutSettingsScreen(navController: NavHostController) { title = "Reorder image actions", dialogTitle = "Image actions", summary = "Customise the order of actions in the image viewer. The top " + - "action will be displayed separately in its own dedicated button.", + "action will be displayed separately in its own dedicated button.", items = currentSettings.imageViewerActions ) { scope.launch { @@ -186,6 +116,56 @@ fun LayoutSettingsScreen(navController: NavHostController) { } } } + + LazyExpressiveGroup("Behaviour") { + item { + EnumPref( + title = "Start page", + summary = currentSettings.defaultStartDestination.label, + enumItems = StartDestination.entries, + selectedItem = currentSettings.defaultStartDestination, + ) { + scope.launch { + preferencesRepository.updatePref( + PreferenceKeys.DEFAULT_START_DESTINATION, + it + ) + } + } + } + /* It's an enum so EnumPref would be the "correct" implementation, + but I think this is a better UX as there are currently only two options. */ + item { + SwitchPref( + checked = currentSettings.defaultBrowseTab == BrowseTab.FOLLOWING, + title = "Prefer Following tab", + summary = "By default, open the Following tab instead of the For You " + + "tab on the Browse page.", + ) { following -> + scope.launch { + preferencesRepository.updatePref( + PreferenceKeys.DEFAULT_BROWSE_TAB, + if (following) BrowseTab.FOLLOWING else BrowseTab.FOR_YOU + ) + } + } + } + item { + EnumPref( + title = "Hide app content", + summary = currentSettings.flagSecureMode.label, + enumItems = FlagSecureMode.entries, + selectedItem = currentSettings.flagSecureMode + ) { + scope.launch { + preferencesRepository.updatePref( + PreferenceKeys.FLAG_SECURE_MODE, + it + ) + } + } + } + } } } } 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 fc0010d4..7678c168 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/Preferences.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/Preferences.kt @@ -146,9 +146,10 @@ fun PreferencesScreen(navController: NavHostController) { item { TitleSummary( modifier = Modifier.fillMaxWidth(), - title = "Behaviour and layout", - summary = "Start page, data saver, visual and layout preferences.", + title = "Appearance and behaviour", + summary = "Appearance, layout, and behaviour options.", leadingIcon = { + // Keeping the original label here for colour reasons ContainedIcon(imageVector = Icons.Rounded.Dashboard, label = "Behaviour and layout") }, trailingIcon = { ChevronRight() } @@ -160,7 +161,7 @@ fun PreferencesScreen(navController: NavHostController) { TitleSummary( modifier = Modifier.fillMaxWidth(), title = "Data and storage", - summary = "Download location and your Breadboard data.", + summary = "Data saver, downloads, and import/export.", leadingIcon = { ContainedIcon(imageVector = Icons.Rounded.Storage, label = "Data and storage") }, From 915e04f5e1eeaa74ac6a75542085b332da7cd172 Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Fri, 28 Aug 2026 04:37:28 +0100 Subject: [PATCH 53/59] Update default image action order --- .../moe/apex/breadboard/preferences/Pref.kt | 29 +++++++++++++++++-- 1 file changed, 27 insertions(+), 2 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 cddc0303..08eb2a34 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/Pref.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/Pref.kt @@ -169,11 +169,15 @@ enum class DataSaver(override val label: String) : PrefEnum { } +/* v3.3.3 changed the order of these. + Previously: DOWNLOAD, TOGGLE_HD, SHARE, FAVOURITE, INFO + After 3.3.3: SHARE, TOGGLE_HD, DOWNLOAD, FAVOURITE, INFO + The first action is displayed in a dedicated FAB in the UI. */ enum class ToolbarAction(override val label: String, override val enabledIcon: ImageVector) : PrefEnum { - DOWNLOAD("Download", Icons.Rounded.Download), + SHARE("Share", Icons.Rounded.Share), TOGGLE_HD("Toggle HD", Icons.Rounded.Hd), + DOWNLOAD("Download", Icons.Rounded.Download), FAVOURITE("Favourite", Icons.Rounded.Favorite), - SHARE("Share", Icons.Rounded.Share), INFO("About", Icons.Rounded.Info) } @@ -617,6 +621,27 @@ class UserPreferencesRepository(private val dataStore: DataStore) { WhatsNewState.show() } + /* Version 3.3.3 changed the default order of the image viewer actions. + Version code 300 was the first version to make them re-orderable. + As that version changed also the design of the image action bar, + I think it's safe to let v2 updaters receive the new order too. + For existing v3 users, we won't change things. */ + if (lastUsedVersionCode in 300..332) { + val data = dataStore.data.first() + if (data[PreferenceKeys.IMAGE_VIEWER_ACTION_ORDER] == null) { + updateEnumList( + key = PreferenceKeys.IMAGE_VIEWER_ACTION_ORDER, + to = listOf( // The original order + ToolbarAction.DOWNLOAD, + ToolbarAction.TOGGLE_HD, + ToolbarAction.FAVOURITE, + ToolbarAction.SHARE, + ToolbarAction.INFO + ) + ) + } + } + /* Always clear the internal ignored list on updates. */ if (BuildConfig.VERSION_CODE != lastUsedVersionCode) { dataStore.edit { prefs -> From 1deac7e3856963e443f577911b9e236d16da9643 Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Fri, 28 Aug 2026 04:38:39 +0100 Subject: [PATCH 54/59] Update version --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 2ed5ce3b..d71ddc93 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -20,8 +20,8 @@ android { applicationId "moe.apex.breadboard" minSdk 26 targetSdk 36 - versionCode 332 - versionName "3.3.2" + versionCode 333 + versionName "3.3.3" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From da412e0ef921baf807667eb1dbf16889c0245f0b Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Fri, 28 Aug 2026 04:45:39 +0100 Subject: [PATCH 55/59] update bouncyAnimationSpec to M3E values --- app/src/main/java/moe/apex/breadboard/util/Ui.kt | 6 +++--- 1 file changed, 3 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 fd53bfac..6560b9b4 100644 --- a/app/src/main/java/moe/apex/breadboard/util/Ui.kt +++ b/app/src/main/java/moe/apex/breadboard/util/Ui.kt @@ -7,7 +7,6 @@ import android.os.Build import android.os.PowerManager import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.FiniteAnimationSpec -import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween @@ -1598,9 +1597,10 @@ fun ButtonListItem( } +// Values taken from expressive motion scheme default spatial spec fun bouncyAnimationSpec(): FiniteAnimationSpec = spring( - dampingRatio = Spring.DampingRatioLowBouncy, - stiffness = Spring.StiffnessMediumLow, + dampingRatio = 0.8f, + stiffness = 380f, ) From e6cf1606687a716628580cdd0282605133d2a0fd Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Fri, 28 Aug 2026 04:48:22 +0100 Subject: [PATCH 56/59] Correctly animate warning in saucenao results --- .../moe/apex/breadboard/saucenao/SauceNaoResultsScreen.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/moe/apex/breadboard/saucenao/SauceNaoResultsScreen.kt b/app/src/main/java/moe/apex/breadboard/saucenao/SauceNaoResultsScreen.kt index 812dd7f2..f01c25d2 100644 --- a/app/src/main/java/moe/apex/breadboard/saucenao/SauceNaoResultsScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/saucenao/SauceNaoResultsScreen.kt @@ -322,7 +322,7 @@ fun SauceNaoResultsScreen( val totalResults = currentState.groups.sumOf { 1 + it.relatedResults.size } if (currentState.header.resultsCount != totalResults) { - item { + item(key = "potentially-explicit-hidden-nag") { Text( text = "Some potentially explicit results have been hidden.\n" + "Adjust this in Content settings.", @@ -330,7 +330,7 @@ fun SauceNaoResultsScreen( color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, modifier = Modifier - .animateItem() + .animateItem(placementSpec = bouncyAnimationSpec()) .fillMaxWidth() .padding(MEDIUM_SPACER.dp) ) From 8d32f878a5ec86cfeaef74e3be6074f4f6e1f6e8 Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Fri, 28 Aug 2026 05:11:49 +0100 Subject: [PATCH 57/59] Default sort following by time followed, add button to change to alphabetical --- .../breadboard/home/FollowedArtistsScreen.kt | 53 ++++++++++++++++++- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/moe/apex/breadboard/home/FollowedArtistsScreen.kt b/app/src/main/java/moe/apex/breadboard/home/FollowedArtistsScreen.kt index 709bb823..bdf0f488 100644 --- a/app/src/main/java/moe/apex/breadboard/home/FollowedArtistsScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/home/FollowedArtistsScreen.kt @@ -11,8 +11,10 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.Sort import androidx.compose.material.icons.rounded.PersonOff import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -22,8 +24,12 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTopAppBarState import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha @@ -53,18 +59,40 @@ import moe.apex.breadboard.util.SMALL_LARGE_SPACER import moe.apex.breadboard.util.Summary import moe.apex.breadboard.util.TINY_SPACER import moe.apex.breadboard.util.generateColours +import moe.apex.breadboard.util.showToast import moe.apex.breadboard.viewmodel.getGlobalViewModel + +private enum class ArtistSortOrder(val label: String) { + ALPHABETICAL("Alphabetical"), + TIME_FOLLOWED("Time followed") +} + + @OptIn(ExperimentalMaterial3Api::class) @Composable fun FollowedArtistsScreen(navController: NavHostController) { + val context = LocalContext.current val viewModel = getGlobalViewModel() val topAppBarState = rememberTopAppBarState() val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(topAppBarState) val scope = rememberCoroutineScope() val userPreferencesRepository = LocalContext.current.prefs val prefs = LocalPreferences.current - val followedTags = prefs.followedTags.toList().sorted() + + val state = rememberLazyListState() + + // TODO: Search bar? + var sortOrder by rememberSaveable { mutableStateOf(ArtistSortOrder.TIME_FOLLOWED) } + val followedTags = remember(prefs.followedTags, sortOrder) { + val list = prefs.followedTags.toList() + if (sortOrder == ArtistSortOrder.ALPHABETICAL) { + list.sorted() + } else { + list.reversed() + } + } + val darkTheme = shouldUseDarkTheme() MainScreenScaffold( @@ -72,11 +100,32 @@ fun FollowedArtistsScreen(navController: NavHostController) { LargeTitleBar( title = "Followed artists", scrollBehavior = scrollBehavior, - navController = navController + navController = navController, + additionalActions = { + IconButton( + onClick = { + scope.launch { + state.scrollToItem(0) + } + sortOrder = if (sortOrder == ArtistSortOrder.ALPHABETICAL) { + ArtistSortOrder.TIME_FOLLOWED + } else { + ArtistSortOrder.ALPHABETICAL + } + showToast(context, "Sort order: ${sortOrder.label}") + } + ) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.Sort, + contentDescription = "Change sort order" + ) + } + } ) } ) { LazyColumn( + state = state, modifier = Modifier .padding(it) .fillMaxSize() From c78e270a7f484c1e232d2eef7d56eef7ea5fd68d Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Fri, 28 Aug 2026 05:24:28 +0100 Subject: [PATCH 58/59] Rename most references to "Image" --- .../main/java/moe/apex/breadboard/favourites/Favourites.kt | 2 +- .../main/java/moe/apex/breadboard/navigation/Navigation.kt | 2 +- .../moe/apex/breadboard/preferences/ApiKeysSettingsScreen.kt | 2 +- .../moe/apex/breadboard/preferences/DataSettingsScreen.kt | 5 +++-- .../moe/apex/breadboard/preferences/GeneralSettingsScreen.kt | 2 +- app/src/main/java/moe/apex/breadboard/preferences/Pref.kt | 2 +- .../main/java/moe/apex/breadboard/preferences/Preferences.kt | 2 +- .../breadboard/preferences/RecommendationsSettingsScreen.kt | 2 +- 8 files changed, 10 insertions(+), 9 deletions(-) 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 12a20558..3e806b3e 100644 --- a/app/src/main/java/moe/apex/breadboard/favourites/Favourites.kt +++ b/app/src/main/java/moe/apex/breadboard/favourites/Favourites.kt @@ -127,7 +127,7 @@ fun FavouritesPage( } }) MainScreenScaffold( - title = "Favourite images", + title = "Favourite posts", scrollBehavior = scrollBehavior, addBottomPadding = false, blur = isImageCarouselVisible && blur, diff --git a/app/src/main/java/moe/apex/breadboard/navigation/Navigation.kt b/app/src/main/java/moe/apex/breadboard/navigation/Navigation.kt index 00214a6f..fcd2e7a2 100644 --- a/app/src/main/java/moe/apex/breadboard/navigation/Navigation.kt +++ b/app/src/main/java/moe/apex/breadboard/navigation/Navigation.kt @@ -214,7 +214,7 @@ fun Navigation(navController: NavHostController, startDestination: Any = Search) icon = { Icon( imageVector = if (currentRoute.routeIs(Favourites::class)) Icons.Rounded.Favorite else Icons.Rounded.FavoriteBorder, - contentDescription = "Favourite images", + contentDescription = "Favourite posts", modifier = Modifier.pulseOnSelect(currentRoute.routeIs(Favourites::class)) ) }, diff --git a/app/src/main/java/moe/apex/breadboard/preferences/ApiKeysSettingsScreen.kt b/app/src/main/java/moe/apex/breadboard/preferences/ApiKeysSettingsScreen.kt index 933734f7..6f0196fa 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/ApiKeysSettingsScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/ApiKeysSettingsScreen.kt @@ -112,7 +112,7 @@ fun ApiKeysSettingsScreen(navController: NavHostController) { contentPadding = PaddingValues(MEDIUM_SPACER.dp) ) { LazyExpressiveGroup( - title = "Image sources", + title = "Content sources", desiredTopPadding = null ) { for (imageBoard in ImageSource.entries) { diff --git a/app/src/main/java/moe/apex/breadboard/preferences/DataSettingsScreen.kt b/app/src/main/java/moe/apex/breadboard/preferences/DataSettingsScreen.kt index d5557180..64804826 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/DataSettingsScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/DataSettingsScreen.kt @@ -214,8 +214,9 @@ fun DataSettingsScreen(navController: NavHostController) { TitleSummary( modifier = Modifier.fillMaxWidth(), title = "Export data", - summary = "Export a backup file containing your current settings, favourite images, " + - "and search history. Downloads are not included." + summary = "Export a backup file containing your current settings," + + "favourite posts, and search history. " + + "Downloads are not included." ) { showExportDialog = true } diff --git a/app/src/main/java/moe/apex/breadboard/preferences/GeneralSettingsScreen.kt b/app/src/main/java/moe/apex/breadboard/preferences/GeneralSettingsScreen.kt index 2dd748fa..6b4c559b 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/GeneralSettingsScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/GeneralSettingsScreen.kt @@ -71,7 +71,7 @@ fun GeneralSettingsScreen(navController: NavHostController) { LazyExpressiveGroup(desiredTopPadding = null) { item { EnumPref( - title = "Image source", + title = "Content source", summary = currentSettings.imageSource.label, enumItems = ImageSource.entries, selectedItem = currentSettings.imageSource, 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 08eb2a34..5a11e982 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/Pref.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/Pref.kt @@ -150,7 +150,7 @@ object PreferenceKeys { enum class PrefCategory(val label: String) { BUILD("Build"), SETTING("Settings"), - FAVOURITE_IMAGES("Favourite images"), + FAVOURITE_IMAGES("Favourite posts"), SEARCH_HISTORY("Search history") } 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 7678c168..627d2cdd 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/Preferences.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/Preferences.kt @@ -108,7 +108,7 @@ fun PreferencesScreen(navController: NavHostController) { TitleSummary( modifier = Modifier.fillMaxWidth(), title = "General", - summary = "Image source, search history, and links.", + summary = "Content source, search history, and links.", leadingIcon = { ContainedIcon(imageVector = Icons.Rounded.Settings, label = "General") }, 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 fe356ec4..8169fd27 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/RecommendationsSettingsScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/RecommendationsSettingsScreen.kt @@ -358,7 +358,7 @@ 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 intelligently use these tags to " + + "favourite posts. 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." ) From 66e827ad6e99ca4693062907dab39108bb5623b1 Mon Sep 17 00:00:00 2001 From: Rob Wainwright <47543882+apex2504@users.noreply.github.com> Date: Fri, 28 Aug 2026 06:01:43 +0100 Subject: [PATCH 59/59] Remove extra message when sharing artist --- .../moe/apex/breadboard/artist/ArtistProfileScreen.kt | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/moe/apex/breadboard/artist/ArtistProfileScreen.kt b/app/src/main/java/moe/apex/breadboard/artist/ArtistProfileScreen.kt index 8dd9692a..77c83199 100644 --- a/app/src/main/java/moe/apex/breadboard/artist/ArtistProfileScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/artist/ArtistProfileScreen.kt @@ -446,11 +446,7 @@ private fun ArtistToolbar(artistTag: String, ) { bottomStart = CornerSize(8.dp) ), onClick = { - shareArtist( - context = context, - artistTag = artistTag, - addFriendlyMessage = true - ) + shareArtist(context = context, artistTag = artistTag) } ) { Text("Share") @@ -805,12 +801,12 @@ private fun SocialChip( } -private fun shareArtist(context: Context, artistTag: String, addFriendlyMessage: Boolean) { +private fun shareArtist(context: Context, artistTag: String) { val shareIntent = Intent(Intent.ACTION_SEND).apply { type = "text/plain" putExtra( Intent.EXTRA_TEXT, - "${if (addFriendlyMessage) "Check out this artist on Breadboard! " else ""}https://breadboard.moe/artist/$artistTag" + "https://breadboard.moe/artist/$artistTag" ) } context.startActivity(Intent.createChooser(shareIntent, null))