diff --git a/PRIVACY.md b/PRIVACY.md index 59526f95..8ca81840 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,59 +1,101 @@ # Privacy Policy for Breadboard -**Last Updated:** 30 July 2025 +**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. -- **No Analytics or Usage Stats:** We do not collect analytics or usage statistics about how you interact with the App. +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. - **Data Storage:** Your search history is stored only on your device. It is not transmitted to us or any third party. +### Reverse Image Search + +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 + +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 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 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 @@ -61,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 diff --git a/app/build.gradle b/app/build.gradle index c7d34750..d71ddc93 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 333 + versionName "3.3.3" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 715f2d5c..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"> + + + + + + + + + + + + + + + @@ -118,6 +141,15 @@ + + + + + + + + + { newIntent -> - val uri = newIntent.data - if (uri != null) { + val uri = newIntent.data ?: return@Consumer + + if (uri.scheme == "breadboard") { + handleBreadboardScheme(uri, prefs.imageSource)?.let { + navController.popBackStack() + navController.navigate(it) + } ?: finishAndRemoveTask() + } else { ImageView.fromUri(uri)?.let { navController.popBackStack() navController.navigate(it) + } ?: ArtistProfile.fromUri(uri, prefs.imageSource)?.let { + navController.popBackStack() + navController.navigate(it) } ?: reopenInBrowser(newIntent) } } @@ -93,15 +105,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, prefs.imageSource)?.let { dest -> + Navigation(navController = navController, startDestination = dest) + } ?: finishAndRemoveTask() + } else { + ImageView.fromUri(it)?.let { iv -> + Navigation(navController = navController, startDestination = iv) + } ?: ArtistProfile.fromUri(it, prefs.imageSource)?.let { ap -> + Navigation(navController = navController, startDestination = ap) + } ?: reopenInBrowser(intent) + } } ?: finishAndRemoveTask() } } } + private fun handleBreadboardScheme(uri: Uri, imageSource: ImageSource): Any? { + val path = uri.pathSegments + if (uri.host == "artist") { + path.getOrNull(0)?.let { + return ArtistProfile(it, imageSource) + } + } + 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..313d2f60 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 @@ -15,7 +16,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 @@ -29,11 +30,14 @@ 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 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 import moe.apex.breadboard.preferences.LocalPreferences import moe.apex.breadboard.preferences.StartDestination @@ -79,6 +83,35 @@ class MainActivity : SingletonImageLoader.Factory, ComponentActivity(), VolumeBu } + private fun maybePrepareArtistDestination(intent: Intent): ArtistProfile? { + val artist = intent.getStringExtra("artist") ?: return null + val source = ImageSource.valueOf(intent.getStringExtra("origin_source") ?: return null) + return ArtistProfile(artist, source) + } + + + 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) + else -> null + } + } + + @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() @@ -106,7 +139,26 @@ class MainActivity : SingletonImageLoader.Factory, ComponentActivity(), VolumeBu val viewModel = getGlobalViewModel() val recommendationsProvider by viewModel.recommendationsProvider.collectAsState() - LaunchedEffect(prefs.imageSource, prefs.filterRatingsLocally) { + /* 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 || recommendationsProvider?.filterRatingsLocally != prefs.filterRatingsLocally @@ -120,7 +172,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 +180,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/RequestUtil.kt b/app/src/main/java/moe/apex/breadboard/RequestUtil.kt index 3d80ecf5..98fb99ab 100644 --- a/app/src/main/java/moe/apex/breadboard/RequestUtil.kt +++ b/app/src/main/java/moe/apex/breadboard/RequestUtil.kt @@ -17,7 +17,41 @@ 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) + + 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 body = it.body.string() + continuation.resume(body) + } else { + 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) @@ -35,8 +69,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 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 new file mode 100644 index 00000000..33e9f825 --- /dev/null +++ b/app/src/main/java/moe/apex/breadboard/artist/Artist.kt @@ -0,0 +1,29 @@ +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 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..77c83199 --- /dev/null +++ b/app/src/main/java/moe/apex/breadboard/artist/ArtistProfileScreen.kt @@ -0,0 +1,813 @@ +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.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 +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.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 +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.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 +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.detailview.FlexibleImageGridDefaults +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.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 +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.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 +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, + originImageSource: ImageSource, + 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 + 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) } + + 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(originImageSource, 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(originImageSource, 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 if (isWideScreen) { + SplitProfileLayout( + navController = navController, + artist = artist, + artistTag = artistTag, + images = images + ) { index, _ -> + selectedImageIndex = index + shouldShowLargeImage = true + } + } else { + SinglePaneProfileLayout( + navController = navController, + artist = artist, + artistTag = artistTag, + images = images + ) { index, _ -> + selectedImageIndex = index + shouldShowLargeImage = true + } + } + } + + // 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 }, + ) +} + + +@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 + val clipboard = LocalClipboard.current + val prefs = LocalPreferences.current + val viewModel = getGlobalViewModel() + + 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) + ) { + val isFollowing = artistTag in prefs.followedTags + val isBlocked = artistTag in prefs.blockedTags + + ButtonGroup( + overflowIndicator = { }, + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.spacedBy(TINY_SPACER.dp) + ) { + customItem( + buttonGroupContent = { + val interactionSource = remember { MutableInteractionSource() } + ToggleButton( + interactionSource = interactionSource, + checked = isFollowing, + enabled = !isBlocked, + modifier = Modifier + .height(48.dp) + .weight(3f) + .animateWidth(interactionSource), + onCheckedChange = { + scope.launch { + if (it) { + preferencesRepository.addToSet( + PreferenceKeys.FOLLOWED_TAGS, + artistTag + ) + } else { + preferencesRepository.removeFromSet( + PreferenceKeys.FOLLOWED_TAGS, + artistTag + ) + } + }.invokeOnCompletion { + viewModel.setFollowingProvider(null) + } + }, + 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 (isBlocked) "Blocked" else 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) + } + ) { + 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", + ) + }.invokeOnCompletion { + showDropdown = false + } + }, + 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, + ) + }.invokeOnCompletion { + showDropdown = false + } + }, + 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 + ) + if (artistTag in prefs.followedTags) { + preferencesRepository.removeFromSet( + PreferenceKeys.FOLLOWED_TAGS, + artistTag + ) + } + } + }.invokeOnCompletion { + viewModel.resetProviders() + showDropdown = false + } + }, + 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(withDivider: Boolean = true) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = LARGE_SPACER.dp, bottom = MEDIUM_SPACER.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(LARGE_SPACER.dp) + ) { + if (withDivider) { + 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 groupedSocials = remember { artist.groupSocials() } + + BreadboardTheme(darkTheme = true) { // We're adding a dark scrim, so assume always dark theme. + Surface(shape = MaterialTheme.shapes.extraLargeIncreased) { + Box { + 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)) + .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) -> + if (site != SocialSite.IMAGEBOARD) { + 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) { + val shareIntent = Intent(Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra( + Intent.EXTRA_TEXT, + "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/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..b7d61d97 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 @@ -31,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 @@ -44,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 @@ -53,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 @@ -67,86 +70,216 @@ private const val MIN_CELL_WIDTH = 120 private const val MAX_CELL_WIDTH = 144 -@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 +interface ImageGridHeaderScope { + fun item(content: @Composable () -> Unit) +} + + +class ImageGridHeaderScopeImpl : ImageGridHeaderScope { + val items = mutableListOf<@Composable () -> Unit>() + + override fun item(content: @Composable () -> Unit) { + items.add(content) + } +} + +object FlexibleImageGridDefaults { @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() + fun WideLoadingIndicator() = WideLinearWavyProgressIndicator(modifier = Modifier.fillMaxWidth()) + + + @OptIn(ExperimentalMaterial3ExpressiveApi::class) + @Composable + fun RoundLoadingIndicator() { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + LoadingIndicator() } } - 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 + fun NoImages(text: String = "No matching posts :(") { + MediumEmphasisCenteredLabel( + text = text, + modifier = Modifier.padding(MEDIUM_SPACER.dp) + ) } +} - AnimatedVisibility ( + +@Composable +private fun ImageGridPullToRefreshContainer( + modifier: Modifier = Modifier, + pullToRefreshController: PullToRefreshController?, + content: @Composable () -> Unit +) { + if (pullToRefreshController != null) { + PullToRefreshBox( + modifier = modifier, + isRefreshing = pullToRefreshController.isRefreshing, + state = pullToRefreshController.state, + onRefresh = pullToRefreshController::refresh, + indicator = { + pullToRefreshController.indicator(this, pullToRefreshController) + }, + enabled = pullToRefreshController.enabled, + content = { content() } + ) + } else { + content() + } +} + + +@Composable +private fun LoadingIndicatorContainer( + modifier: Modifier = Modifier, + doneInitialLoad: Boolean, + contentPadding: PaddingValues, + onInitialLoadCompleted: suspend () -> Unit, + content: @Composable () -> Unit +) { + 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) { if (doneInitialLoad) { - staggeredGridState.requestScrollToItem(0) - uniformGridState.requestScrollToItem(0) + onInitialLoadCompleted() + } + } + } +} + + +@Composable +fun FlexibleImageGrid( + staggered: Boolean, + modifier: Modifier = Modifier, + userScrollEnabled: Boolean = true, + images: List, + onImageClick: (Int, Image) -> Unit, + 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, + contentPadding = contentPadding, + headerItems = headerItems, + pullToRefreshController = pullToRefreshController, + doneInitialLoad = doneInitialLoad, + loadingIndicator = loadingIndicator, + onEndReached = onEndReached + ) +} + + +@Composable +fun FlexibleImageGrid( + gridState: ScrollableState, + modifier: Modifier = Modifier, + userScrollEnabled: Boolean = true, + images: List, + onImageClick: (Int, Image) -> Unit, + 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() + if (headerItems != null) { + scope.headerItems() + } + + when (gridState) { + is LazyStaggeredGridState -> { + Box(modifier = modifier) { + ImageGridPullToRefreshContainer( + modifier = Modifier.fillMaxSize(), + pullToRefreshController = pullToRefreshController + ) { + StaggeredImageGrid( + modifier = Modifier.fillMaxSize(), + userScrollEnabled = userScrollEnabled, + gridState = gridState, + contentPadding = contentPadding, + headerItems = scope.items, + images = images, + noImagesContent = noImagesContent, + onImageClick = onImageClick, + onEndReached = onEndReached + ) + } + + LoadingIndicatorContainer( + modifier = Modifier.fillMaxSize(), + doneInitialLoad = doneInitialLoad, + contentPadding = contentPadding, + onInitialLoadCompleted = { gridState.requestScrollToItem(0) }, + content = loadingIndicator + ) } } + + is LazyGridState -> { + ImageGridPullToRefreshContainer( + modifier = modifier, + pullToRefreshController = pullToRefreshController + ) { + Box(modifier = Modifier.fillMaxSize()) { + UniformImageGrid( + modifier = Modifier.fillMaxSize(), + userScrollEnabled = userScrollEnabled, + gridState = gridState, + contentPadding = contentPadding, + headerItems = scope.items, + images = images, + noImagesContent = noImagesContent, + onImageClick = onImageClick, + onEndReached = onEndReached + ) + + LoadingIndicatorContainer( + modifier = Modifier.fillMaxSize(), + doneInitialLoad = doneInitialLoad, + contentPadding = contentPadding, + onInitialLoadCompleted = { gridState.scrollToItem(0) }, + content = loadingIndicator + ) + } + } + } + + else -> { + throw IllegalArgumentException("gridState must be either LazyStaggeredGridState or LazyGridState") + } } } @@ -154,9 +287,10 @@ fun ImageGrid( @Composable private fun StaggeredImageGrid( modifier: Modifier = Modifier, + userScrollEnabled: Boolean = true, gridState: LazyStaggeredGridState = rememberLazyStaggeredGridState(), contentPadding: PaddingValues, - filterComposable: (@Composable () -> Unit)? = null, + headerItems: (List<@Composable () -> Unit>)? = null, images: List, noImagesContent: @Composable () -> Unit, onImageClick: (Int, Image) -> Unit, @@ -170,11 +304,18 @@ private fun StaggeredImageGrid( modifier = modifier, contentPadding = contentPadding, horizontalArrangement = Arrangement.spacedBy(SMALL_SPACER.dp), - verticalItemSpacing = SMALL_SPACER.dp + verticalItemSpacing = SMALL_SPACER.dp, + userScrollEnabled = userScrollEnabled ) { - filterComposable?.let { - item(key = "ratings-filter", span = StaggeredGridItemSpan.FullLine ) { - it() + headerItems?.forEachIndexed { index, element -> + item(key = "header-$index", span = StaggeredGridItemSpan.FullLine) { + element() + } + } + + if (images.isEmpty()) { + item(key = "no-images", span = StaggeredGridItemSpan.FullLine) { + noImagesContent() } } @@ -192,12 +333,6 @@ private fun StaggeredImageGrid( } } - if (images.isEmpty()) { - item(key = "no-images", span = StaggeredGridItemSpan.FullLine) { - noImagesContent() - } - } - item(key = "spacer", span = StaggeredGridItemSpan.FullLine) { NavBarHeightVerticalSpacer() } @@ -208,9 +343,10 @@ private fun StaggeredImageGrid( @Composable private fun UniformImageGrid( modifier: Modifier = Modifier, + userScrollEnabled: Boolean = true, gridState: LazyGridState = rememberLazyGridState(), contentPadding: PaddingValues, - filterComposable: (@Composable () -> Unit)? = null, + headerItems: List<@Composable () -> Unit>? = null, images: List, noImagesContent: @Composable () -> Unit, onImageClick: (Int, Image) -> Unit, @@ -224,11 +360,18 @@ 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 ) { - filterComposable?.let { - item(key = "ratings-filter", span = { GridItemSpan(maxLineSpan) }) { - it() + headerItems?.forEachIndexed { index, element -> + item(key = "header-$index", span = { GridItemSpan(maxLineSpan) }) { + element() + } + } + + if (images.isEmpty()) { + item(key = "no-images", span = { GridItemSpan(maxLineSpan) }) { + noImagesContent() } } @@ -246,12 +389,6 @@ private fun UniformImageGrid( } } - if (images.isEmpty()) { - item(key = "no-images", span = { GridItemSpan(maxLineSpan) }) { - noImagesContent() - } - } - item(key = "spacer", span = { GridItemSpan(maxLineSpan) }) { NavBarHeightVerticalSpacer() } @@ -259,16 +396,6 @@ private fun UniformImageGrid( } -@Composable -fun NoImages() { - Text( - text = "No images :(", - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth() - ) -} - - @Composable private fun LazyGridItemScope.ImagePreviewContainer( image: Image, @@ -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/detailview/SearchResults.kt b/app/src/main/java/moe/apex/breadboard/detailview/SearchResults.kt index 69a03950..a0107427 100644 --- a/app/src/main/java/moe/apex/breadboard/detailview/SearchResults.kt +++ b/app/src/main/java/moe/apex/breadboard/detailview/SearchResults.kt @@ -1,20 +1,15 @@ 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 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 @@ -35,29 +30,25 @@ 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.refreshImageMetadata import moe.apex.breadboard.util.rememberPullToRefreshController -import moe.apex.breadboard.util.withoutVertical import moe.apex.breadboard.viewmodel.SearchResultsViewModel @@ -86,6 +77,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( @@ -97,7 +93,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) @@ -175,8 +171,7 @@ fun SearchResults(navController: NavController, source: ImageSource, tagList: Li additionalActions = { if (doneInitialLoad) { ScrollToTopArrow( - staggeredGridState = viewModel.staggeredGridState, - uniformGridState = viewModel.uniformGridState, + scrollableState = state, animate = !filterLocally || Experiment.ALWAYS_ANIMATE_SCROLL.isEnabled(), ) } @@ -192,27 +187,24 @@ 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) { 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 { @@ -220,18 +212,28 @@ 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, - noImagesContent = { if (doneInitialLoad) { NoImages() } } + onEndReached = viewModel::loadMore ) } @@ -241,37 +243,11 @@ fun SearchResults(navController: NavController, source: ImageSource, tagList: Li initialSelectedImageIndex = selectedImageIndex, allImages = imagesToDisplay, onActiveStateChanged = { isImageCarouselVisible = it } - ) { oldImage, newImage -> - viewModel.updateImage(oldImage, newImage) - } -} - - - -@Composable -fun ApiKeyRequiredColumn( - modifier: Modifier = Modifier, - source: ImageSource, - onClick: () -> Unit -) { - Column( - modifier = modifier, - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(MEDIUM_SPACER.dp) - ) { - ExpressiveContainer(position = ListItemPosition.SINGLE_ELEMENT) { - TitleSummary( - title = "API Key required", - summary = "${source.label} requires an API key to search.\n" + - "Add an API key in Settings.\n" + - "Alternatively, use a different image source.", - ) - } - Button( - onClick = onClick, - colors = ButtonDefaults.buttonColors() - ) { - Text("Go to Settings") + ) { image -> + if (!image.hasGroupedTags) { + refreshImageMetadata(image, prefs.authFor(image.imageSource, context)) { + viewModel.updateImage(image, it) + } } } -} \ No newline at end of file +} 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..3e806b3e 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 @@ -37,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 @@ -48,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() @@ -65,6 +68,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( @@ -118,32 +127,27 @@ fun FavouritesPage( } }) MainScreenScaffold( - title = "Favourite images", + title = "Favourite posts", scrollBehavior = scrollBehavior, addBottomPadding = false, 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) } } ) { padding -> - ImageGrid( + FlexibleImageGrid( + gridState = state, modifier = Modifier .padding(padding) .nestedScroll(scrollBehavior.nestedScrollConnection) - .onScroll(viewModel.staggeredGridState) { - navBarVisibilityCallback(!it.lastScrolledForward) - } - .onScroll(viewModel.uniformGridState) { + .onScroll(state) { navBarVisibilityCallback(!it.lastScrolledForward) - }, - staggeredGridState = viewModel.staggeredGridState, - uniformGridState = viewModel.uniformGridState, + }, images = images, onImageClick = { index, _ -> Snapshot.withMutableSnapshot { @@ -151,13 +155,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 + ) + } } ) } @@ -171,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/FollowedArtistsScreen.kt b/app/src/main/java/moe/apex/breadboard/home/FollowedArtistsScreen.kt new file mode 100644 index 00000000..bdf0f488 --- /dev/null +++ b/app/src/main/java/moe/apex/breadboard/home/FollowedArtistsScreen.kt @@ -0,0 +1,228 @@ +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.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.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 +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.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 +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.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 +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.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 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( + topAppBar = { + LargeTitleBar( + title = "Followed artists", + scrollBehavior = scrollBehavior, + 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() + .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, ImageSource.DANBOORU)) + }, + 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 351f892f..4b7b9880 100644 --- a/app/src/main/java/moe/apex/breadboard/home/HomeScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/home/HomeScreen.kt @@ -1,24 +1,54 @@ 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 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 +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 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 +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 +import androidx.compose.material3.ToggleButtonDefaults 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 +import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -29,31 +59,55 @@ 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 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.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 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 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.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 -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) @Composable fun HomeScreen( navController: NavController, @@ -63,7 +117,14 @@ 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 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) @@ -72,37 +133,95 @@ fun HomeScreen( var shouldShowLargeImage by remember { mutableStateOf(false) } var selectedImageIndex by remember { mutableIntStateOf(0) } - val blur = prefs.isExperimentEnabled(Experiment.IMMERSIVE_UI_EFFECTS) + 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 + } - val _onRefresh: suspend () -> Unit = { - recommendationsProvider?.let { - it.replaceBlockedTags(blockedTags) - it.replaceUnfollowedTags(unfollowedTags + builtInIgnoredTags) - it.prepareRecommendedTags() - it.recommendImages() - it.resetGridStates() - } + SideEffect(pagerState.currentPage) { + viewModel.setDefaultBrowseTab(BrowseTab.entries[pagerState.currentPage]) } - val onRefresh by rememberUpdatedState(_onRefresh) - 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", + additionalActions = { + val currentProvider = + if (pagerState.currentPage == BrowseTab.entries.indexOf(BrowseTab.FOR_YOU)) { + recommendationsProvider + } else { + followingProvider + } + currentProvider?.let { + ScrollToTopArrow( + scrollableState = if (prefs.useStaggeredGrid) it.staggeredGridState else it.uniformGridState, + animate = true + ) { + navBarVisibilityCallback(true) + } + } + }, + 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 + ) ) { - scrollBehavior.state.contentOffset = 0f + 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", + ) + } + } } } - } + }, + addBottomPadding = false, + blur = shouldShowLargeImage && blur, ) { padding -> LaunchedEffect(Unit) { if (differenceOlderThan(7.days, prefs.internalIgnoreListTimestamp)) { @@ -121,15 +240,18 @@ fun HomeScreen( } if (builtInIgnoredTags.isEmpty()) { - WideLinearWavyProgressIndicator( + Box( modifier = Modifier .fillMaxWidth() .padding(padding) - .padding(SMALL_LARGE_SPACER.dp) - ) + .offset(y = SMALL_LARGE_SPACER.dp), + contentAlignment = Alignment.TopCenter + ) { + LoadingIndicator() + } } else { if (recommendationsProvider == null) { - LaunchedEffect(Unit) { + SideEffect(Unit) { val newProvider = RecommendationsProvider( seedImages = prefs.favouriteImages, imageSource = prefs.imageSource, @@ -145,76 +267,368 @@ fun HomeScreen( viewModel.setRecommendationsProvider(newProvider) } } + if (followingProvider == null) { + SideEffect(Unit) { + val newProvider = FollowingProvider( + followedArtists = prefs.followedTags, + showAllRatings = prefs.recommendAllRatings, + initialBlockedTags = prefs.blockedTags + ) + viewModel.setFollowingProvider(newProvider) + } + } } - recommendationsProvider?.let { provider -> - ImageGrid( - modifier = Modifier - .padding(padding) - .nestedScroll(scrollBehavior.nestedScrollConnection) - .onScroll(provider.staggeredGridState) { - navBarVisibilityCallback(!it.lastScrolledForward) - } - .onScroll(provider.uniformGridState) { - navBarVisibilityCallback(!it.lastScrolledForward) - }, - staggeredGridState = provider.staggeredGridState, - uniformGridState = provider.uniformGridState, - images = provider.recommendedImages, - noImagesContent = { - if (!provider.doneInitialLoad) { - return@ImageGrid + HorizontalPager( + state = pagerState, + key = { BrowseTab.entries[it].name }, + modifier = Modifier + .fillMaxSize() + .padding(padding), + beyondViewportPageCount = 1, + verticalAlignment = Alignment.Top, + 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") + } } - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally + } + + /* 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. " ) { - Text("No recommendations right now.") - TextButton( + OutlinedButton( onClick = { - pullToRefreshController.refresh(animate = true) - } + navController.navigate(GeneralSettings) + }, + shapes = ButtonDefaults.shapes() + ) { + Text("Change source") + } + + Button( + onClick = { + navController.navigate(RecommendationsSettings) + }, + shapes = ButtonDefaults.shapes() ) { - Icon(Icons.Rounded.Refresh, contentDescription = null) - Spacer(Modifier.width(SMALL_SPACER.dp)) - Text("Refresh") + Text("Go to settings") } } - }, - onImageClick = { index, _ -> - Snapshot.withMutableSnapshot { - selectedImageIndex = index - shouldShowLargeImage = true + } + + recommendationsProvider?.let { provider -> + val state = if (prefs.useStaggeredGrid) { + provider.staggeredGridState + } else { + provider.uniformGridState } - }, - 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 onRefresh: suspend () -> Unit by rememberUpdatedState { + provider.let { + it.replaceBlockedTags(blockedTags) + it.replaceUnfollowedTags(unfollowedTags + builtInIgnoredTags) + it.prepareRecommendedTags() + it.recommendImages() + it.resetGridStates() + } + } + + val ptrController = rememberPullToRefreshController( + enabled = provider.doneInitialLoad, + onRefresh = onRefresh + ) + + FlexibleImageGrid( + gridState = state, + modifier = Modifier + .nestedScroll(scrollBehavior.nestedScrollConnection) + .onScroll(state) { + navBarVisibilityCallback(!it.lastScrolledForward) + }, + userScrollEnabled = !shouldShowLargeImage, + images = recommendedImages, + onImageClick = { index, _ -> + Snapshot.withMutableSnapshot { + selectedImageIndex = index + shouldShowLargeImage = true + } + }, + noImagesContent = { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + FlexibleImageGridDefaults.NoImages("No recommendations right now.") + Button( + onClick = { + ptrController.refresh(animate = true) + }, + shapes = ButtonDefaults.shapes() + ) { + Icon( + imageVector = Icons.Rounded.Refresh, + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(Modifier.width(ButtonDefaults.IconSpacing)) + 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 if (page == BrowseTab.entries.indexOf(BrowseTab.FOLLOWING)) { + followingProvider?.let { provider -> + val state = if (prefs.useStaggeredGrid) { + provider.staggeredGridState + } else { + provider.uniformGridState + } + + val onRefresh: suspend () -> Unit by rememberUpdatedState { + provider.let { + it.replaceBlockedTags(blockedTags) + it.replaceFollowedArtists(prefs.followedTags) + it.reset() + it.loadMore() + it.resetGridStates() + } + } + + val ptrController = rememberPullToRefreshController( + enabled = followingImages.isNotEmpty() || + (provider.doneInitialLoad && prefs.followedTags.isNotEmpty()), + onRefresh = onRefresh + ) + + FlexibleImageGrid( + gridState = state, + modifier = Modifier + .nestedScroll(scrollBehavior.nestedScrollConnection) + .onScroll(state) { + navBarVisibilityCallback(!it.lastScrolledForward) + }, + userScrollEnabled = !shouldShowLargeImage, + images = followingImages, + onImageClick = { index, _ -> + Snapshot.withMutableSnapshot { + selectedImageIndex = index + shouldShowLargeImage = true + } + }, + noImagesContent = { + if (!provider.doneInitialLoad) { + return@FlexibleImageGrid + } + Column(horizontalAlignment = Alignment.CenterHorizontally) { + 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 { + 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 { + FlexibleImageGridDefaults.NoImages("No new posts from your followed artists.") + Button( + onClick = { + ptrController.refresh(animate = true) + }, + shapes = ButtonDefaults.shapes() + ) { + Icon( + imageVector = Icons.Rounded.Refresh, + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(Modifier.width(ButtonDefaults.IconSpacing)) + 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 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) + } } } } + + +@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( + text = "Welcome to your Following feed!", + style = MaterialTheme.typography.prefTitle, + color = MaterialTheme.colorScheme.primary, + textAlign = TextAlign.Center + ) + Text( + text = "Let's get started by following some of your favourite artists.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + + 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 + ) + } + } + + Button( + onClick = { + onFollow(selectedForFollow.takeIf { it.isNotEmpty() } ?: suggestedArtists) + } + ) { + Text( + text = if (selectedForFollow.isEmpty()) { + "Follow all" + } else { + "Follow selected" + } + ) + } + } +} + + +@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/Image.kt b/app/src/main/java/moe/apex/breadboard/image/Image.kt index 08829866..70b15d14 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() } @@ -71,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/image/ImageBoard.kt b/app/src/main/java/moe/apex/breadboard/image/ImageBoard.kt index d844ba9b..881557bb 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.social.SocialEntry +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,27 @@ 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" + + 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 @@ -496,6 +520,41 @@ 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) + SocialEntry( + 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") + ) + } + ) + } + override fun getRatingFromString(rating: String): ImageRating { return when (rating) { @@ -509,6 +568,61 @@ object Danbooru : ImageBoard { } +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() + + 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() + } + + + 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() + } +} + + object Yandere : ImageBoard { override val baseUrl = "https://yande.re/" override val autoCompleteSearchUrl = "${baseUrl}tag.json?limit=10&order=count&name=%s" 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..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 @@ -51,7 +56,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 @@ -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 @@ -72,6 +78,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 @@ -94,6 +101,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 @@ -113,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) { @@ -148,7 +164,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() } @@ -156,14 +172,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, artistProfileForUncategorisedTags: Boolean = false) { 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 || (category == TagCategory.GENERAL && artistProfileForUncategorisedTags)) { + createArtistIntent(context, tag, image.imageSource) + } else { + createSearchIntent(context, image.imageSource, tag) + } context.startActivity(intent) + } else if (category == TagCategory.ARTIST || (category == TagCategory.GENERAL && artistProfileForUncategorisedTags)) { + navController.navigate(ArtistProfile(tag, image.imageSource)) } else { navController.navigate(Results(image.imageSource, listOf(tag))) } @@ -206,15 +229,34 @@ 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 = "Search", - icon = Icons.Rounded.Search, + label = action.label, + icon = action.icon, modifier = Modifier.fillMaxWidth(), position = ListItemPosition.TOP ) { val searchTag = selectedTag!! + val category = selectedTagCategory!! selectedTag = null - startTagSearch(searchTag) + selectedTagCategory = null + hideAndThen { + startTagSearch( + tag = searchTag, + category = category, + artistProfileForUncategorisedTags = prefs.profilesForAllTags + ) + } } ButtonListItem( label = "Copy to clipboard", @@ -259,6 +301,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!!}") @@ -281,7 +329,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 +346,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 +363,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 +373,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 +464,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 +488,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 +512,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 +566,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 +624,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 { @@ -612,7 +661,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) } } @@ -625,8 +674,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) { @@ -642,7 +691,7 @@ private fun LazyListScope.imageboardDataContentItems( } } - LazyExpressiveGroup(useBox = true) { + LazyExpressiveGroup(clip = false) { if (unified) { mainTagsItems(image, onTagClick, onTagLongClick) } @@ -692,8 +741,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 +788,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 @@ -750,7 +799,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 @@ -791,8 +840,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 +869,27 @@ 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, originImageSource: ImageSource): Intent { + return createMainActivityIntent(context, "artist") + .putExtra("artist", artistTag) + .putExtra("origin_source", originImageSource.name) } -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/largeimageview/LargeImageView.kt b/app/src/main/java/moe/apex/breadboard/largeimageview/LargeImageView.kt index 6d918b34..046e25ca 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 @@ -165,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 @@ -201,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( @@ -222,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) @@ -233,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) { @@ -281,14 +267,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) } @@ -664,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 + } + } + } ) + } } @@ -939,17 +935,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 +956,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 { @@ -1188,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 @@ -1258,7 +1254,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/Destinations.kt b/app/src/main/java/moe/apex/breadboard/navigation/Destinations.kt index ee44485b..c44a1d53 100644 --- a/app/src/main/java/moe/apex/breadboard/navigation/Destinations.kt +++ b/app/src/main/java/moe/apex/breadboard/navigation/Destinations.kt @@ -81,9 +81,15 @@ data class ImageView( @Serializable object Home +@Serializable +object FollowedArtists + @Serializable object Search +@Serializable +data class ReverseSearch(val initialImageUrl: String? = null, val initialFileUri: String? = null) + @Serializable data class Results( val source: ImageSource, @@ -96,6 +102,21 @@ object Favourites @Serializable object Settings +@Serializable +object GeneralSettings + +@Serializable +object ContentSettings + +@Serializable +object ApiKeysSettings + +@Serializable +object LayoutSettings + +@Serializable +object DataSettings + @Serializable object BlockedTagsSettings @@ -114,6 +135,31 @@ object RecommendationsSettings @Serializable object IgnoredTagsSettings +@Serializable +data class SauceNaoResults(val imageUrl: String = "", val fileUri: String = "") + +@Serializable +data class ArtistProfile( + val artistTag: String, + val originImageSource: ImageSource, +) { + companion object { + 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, imageSource) + } + } + 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..fcd2e7a2 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 @@ -19,16 +20,17 @@ 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 +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf 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 @@ -43,19 +45,29 @@ 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 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 +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 +import moe.apex.breadboard.preferences.ApiKeysSettingsScreen +import moe.apex.breadboard.util.WhatsNew @Composable @@ -80,153 +92,251 @@ 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 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 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, + ApiKeysSettings::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, 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. */ - LaunchedEffect(currentRoute) { + SideEffect(currentRoute) { if (currentRoute.routeIs(topLevelScreens)) { isNavigationBarVisible = true } } 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 } - } + WhatsNew() + + 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(homeScreens), + icon = { + Icon( + painter = painterResource(if (currentRoute.routeIs(homeScreens)){ + R.drawable.ic_home_filled + } else { + R.drawable.ic_home_hollow + }), + contentDescription = "Browse", + modifier = Modifier.pulseOnSelect(currentRoute.routeIs(homeScreens)) + ) + }, + onClick = { + /* 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 } } } - ) - 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", + modifier = Modifier.pulseOnSelect(currentRoute.routeIs(searchScreens)) + ) + }, + 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("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", + modifier = Modifier.pulseOnSelect(currentRoute.routeIs(reverseSearchScreens)) + ) + }, + onClick = { + if (!currentRoute.routeIs(ReverseSearch::class)) { + navController.navigate(ReverseSearch()) { + popUpTo(ReverseSearch()) { 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("Favourites") }, + selected = currentRoute.routeIs(Favourites::class), + icon = { + Icon( + imageVector = if (currentRoute.routeIs(Favourites::class)) Icons.Rounded.Favorite else Icons.Rounded.FavoriteBorder, + contentDescription = "Favourite posts", + modifier = Modifier.pulseOnSelect(currentRoute.routeIs(Favourites::class)) + ) + }, + 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", + modifier = Modifier.pulseOnSelect(currentRoute.routeIs(settingsScreens)) + ) + }, + onClick = { + /* 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) { + 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) } + } + ) { 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() + ReverseSearchScreen(navController, args.initialImageUrl, args.initialFileUri) + } + composable { + val args = it.toRoute() + SearchResults(navController, args.source, args.tags) + } + composable { FavouritesPage(navController) { isNavigationBarVisible = it } } + composable { PreferencesScreen(navController) } + composable { GeneralSettingsScreen(navController) } + composable { ApiKeysSettingsScreen(navController) } + composable { ContentSettingsScreen(navController) } + composable { LayoutSettingsScreen(navController) } + composable { DataSettingsScreen(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, args.originImageSource, navController = navController) + } + composable { FollowedArtistsScreen(navController) } + composable { + val args = it.toRoute() + SauceNaoResultsScreen(navController, args.imageUrl, args.fileUri) } } } } } + + +@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 + } +} 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..d6666cb5 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/AboutScreen.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/AboutScreen.kt @@ -6,6 +6,7 @@ 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 @@ -35,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 @@ -45,13 +51,14 @@ 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.SMALL_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 +108,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 +126,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++ @@ -143,49 +152,78 @@ 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 + ) + } } } - item { - ExpressiveGroup("Maintainer") { - item { - GitHubUserContainer(apex2504) - } + + LazyExpressiveGroup("Maintainer") { + 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( + 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/ApiKeysSettingsScreen.kt b/app/src/main/java/moe/apex/breadboard/preferences/ApiKeysSettingsScreen.kt new file mode 100644 index 00000000..6f0196fa --- /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 = "Content 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/BlockedTagsScreen.kt b/app/src/main/java/moe/apex/breadboard/preferences/BlockedTagsScreen.kt index 4df32be0..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 @@ -112,7 +119,7 @@ fun BlockedTagsScreen(navController: NavHostController) { } } showAddDialog = false - viewModel.setRecommendationsProvider(null) + viewModel.resetProviders() } ) { Text("Add") @@ -222,7 +229,7 @@ fun BlockedTagsScreen(navController: NavHostController) { tag ) } - viewModel.setRecommendationsProvider(null) + viewModel.resetProviders() } ) { Icon( 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..577e1982 --- /dev/null +++ b/app/src/main/java/moe/apex/breadboard/preferences/ContentSettingsScreen.kt @@ -0,0 +1,202 @@ +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.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.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 +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 + + var showAgeVerificationDialog by remember { mutableStateOf(false) } + + if (showAgeVerificationDialog) { + AgeVerification.AgeVerifyDialog( + onDismissRequest = { showAgeVerificationDialog = false }, + onAgeVerified = { showAgeVerificationDialog = false } + ) + } + + 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 recommendations", + summary = "Fine-tune your recommendations by customising which tags can be used to recommend new content.", + trailingIcon = { ChevronRight() } + ) { + navController.navigate(RecommendationsSettings) + } + } + 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("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("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( + 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 { + SwitchPref( + checked = currentSettings.profilesForAllTags, + 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 without a dedicated category." + ) { + scope.launch { + preferencesRepository.updatePref( + PreferenceKeys.PROFILES_FOR_ALL_TAGS, + 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..64804826 --- /dev/null +++ b/app/src/main/java/moe/apex/breadboard/preferences/DataSettingsScreen.kt @@ -0,0 +1,247 @@ +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 + val preferencesRepository = context.prefs + + 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 { + 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 + } + } + } + + LazyExpressiveGroup("Your Breadboard data") { + item { + TitleSummary( + modifier = Modifier.fillMaxWidth(), + title = "Export data", + summary = "Export a backup file containing your current settings," + + "favourite posts, 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..6b4c559b --- /dev/null +++ b/app/src/main/java/moe/apex/breadboard/preferences/GeneralSettingsScreen.kt @@ -0,0 +1,150 @@ +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.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.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.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 scope = rememberCoroutineScope() + + var showAgeVerificationDialog by remember { mutableStateOf(false) } + + val preferencesRepository = LocalContext.current.prefs + val currentSettings = LocalPreferences.current + + 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 = "Content 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(), + 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." + }, + trailingIcon = { ChevronRight() } + ) { + navController.navigate(ApiKeysSettings) + } + } + 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..a4e19e48 --- /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.ui.theme.doesSystemSupportDarkTheme +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 = "Appearance and layout", + desiredTopPadding = null + ) { + 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", + 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 + ) + } + } + } + } + + 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/Pref.kt b/app/src/main/java/moe/apex/breadboard/preferences/Pref.kt index ef0db442..5a11e982 100644 --- a/app/src/main/java/moe/apex/breadboard/preferences/Pref.kt +++ b/app/src/main/java/moe/apex/breadboard/preferences/Pref.kt @@ -45,10 +45,12 @@ 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 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 @@ -97,6 +99,12 @@ 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" + const val SAUCENAO_API_KEY = "saucenao_api_key" + const val SAUCENAO_ALLOW_NSFW = "saucenao_allow_nsfw" } @@ -130,13 +138,19 @@ 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) + val SAUCENAO_API_KEY = stringPreferencesKey(PrefNames.SAUCENAO_API_KEY) + val SAUCENAO_ALLOW_NSFW = booleanPreferencesKey(PrefNames.SAUCENAO_ALLOW_NSFW) } enum class PrefCategory(val label: String) { BUILD("Build"), SETTING("Settings"), - FAVOURITE_IMAGES("Favourite images"), + FAVOURITE_IMAGES("Favourite posts"), SEARCH_HISTORY("Search history") } @@ -155,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) } @@ -183,6 +201,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."), @@ -226,7 +249,13 @@ data class Prefs( val internalIgnoreListTimestamp: Long, val internalIgnoreList: Set, val autoplayVideos: AutoplayVideosMode, - val unifiedInfoSheet: Boolean + val unifiedInfoSheet: Boolean, + val darkTheme: DarkTheme, + val followedTags: Set, + val defaultBrowseTab: BrowseTab, + val profilesForAllTags: Boolean, + val saucenaoApiKey: String, + val saucenaoAllowNsfw: Boolean, ) { companion object { val DEFAULT = Prefs( @@ -259,6 +288,12 @@ 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, + saucenaoApiKey = "", + saucenaoAllowNsfw = false, ) } @@ -273,7 +308,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() + } } @@ -321,7 +358,13 @@ 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.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), + PreferenceKeys.SAUCENAO_API_KEY to PrefMeta(PrefCategory.SETTING, exportable = false), + PreferenceKeys.SAUCENAO_ALLOW_NSFW to PrefMeta(PrefCategory.SETTING), ) } @@ -360,6 +403,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. */ @@ -560,6 +615,33 @@ 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() + } + + /* 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 -> @@ -819,6 +901,12 @@ 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 + val saucenaoApiKey = preferences[PreferenceKeys.SAUCENAO_API_KEY] ?: Prefs.DEFAULT.saucenaoApiKey + val saucenaoAllowNsfw = preferences[PreferenceKeys.SAUCENAO_ALLOW_NSFW] ?: Prefs.DEFAULT.saucenaoAllowNsfw return Prefs( dataSaver, @@ -849,7 +937,13 @@ class UserPreferencesRepository(private val dataStore: DataStore) { internalIgnoreListTimestamp, internalIgnoreList, autoplayVideos, - unifiedInfoSheet + unifiedInfoSheet, + darkTheme, + followedTags, + defaultBrowseTab, + profilesForAllTags, + saucenaoApiKey, + saucenaoAllowNsfw, ) } } @@ -864,3 +958,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 a2e54d8d..627d2cdd 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,58 @@ 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.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.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.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.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.ApiKeysSettings +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.ui.theme.shouldUseDarkTheme 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 +import moe.apex.breadboard.util.generateColours @OptIn(ExperimentalMaterial3Api::class) @@ -92,141 +60,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.setRecommendationsProvider(null) - } - } - - 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 +76,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,473 +101,116 @@ 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 - ) - } - } - } - } - } - - 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.setRecommendationsProvider(null) - } - } - 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 { - 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 - ) - } - } - } - 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 { - 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 { - 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 - } + LazyExpressiveGroup(desiredTopPadding = null) { + item { + TitleSummary( + modifier = Modifier.fillMaxWidth(), + title = "General", + summary = "Content source, search history, and links.", + leadingIcon = { + ContainedIcon(imageVector = Icons.Rounded.Settings, label = "General") + }, + trailingIcon = { ChevronRight() } + ) { + navController.navigate(GeneralSettings) } } - } - } - } -} - - -@Composable -private 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 + 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() } ) { - DropdownMenuItem( - text = { Text(if (inQueryParamMode) "Enter credentials normally" else "Enter query string") }, - onClick = { - inQueryParamMode = !inQueryParamMode - showMenu = false - } - ) + navController.navigate(ApiKeysSettings) } } - } - }, - 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 + item { + TitleSummary( + modifier = Modifier.fillMaxWidth(), + title = "Content", + summary = "Blocked tags, recommendations, AI filtering, and video settings.", + leadingIcon = { + ContainedIcon(imageVector = Icons.Rounded.Visibility, label = "Content") + }, + trailingIcon = { ChevronRight() } ) { - userId = it.trim() + navController.navigate(ContentSettings) } } - - AnimatedVisibility(!inQueryParamMode) { - PreferenceTextBox( - modifier = Modifier.padding(top = SMALL_SPACER.dp), - value = apiKey, - label = "API key", - keyboardType = KeyboardType.Password, - obscured = true + item { + TitleSummary( + modifier = Modifier.fillMaxWidth(), + 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() } ) { - apiKey = it.trim() + navController.navigate(LayoutSettings) } } - - 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...") - } + item { + TitleSummary( + modifier = Modifier.fillMaxWidth(), + title = "Data and storage", + summary = "Data saver, downloads, and import/export.", + leadingIcon = { + ContainedIcon(imageVector = Icons.Rounded.Storage, label = "Data and storage") + }, + trailingIcon = { ChevronRight() } + ) { + navController.navigate(DataSettings) } - - 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) + 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) } } - ) { - Text("Save") - } - }, - dismissButton = { - TextButton(onClick = onDismissRequest) { - Text("Cancel") } } - ) + } } -private operator fun JSONObject.contains(key: String): Boolean { - return try { - get(key) - true - } catch (_: JSONException) { - false +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun ContainedIcon( + label: String, + imageVector: ImageVector +) { + val darkTheme = shouldUseDarkTheme() + val colourPair = remember(label) { + generateColours(darkTheme, label) + } + + Box( + modifier = Modifier + .clip(CircleShape) + .background(colourPair.first) + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = imageVector, + contentDescription = null, + tint = colourPair.second + ) } } 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..8169fd27 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() } } @@ -362,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." ) @@ -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/saucenao/ReverseSearchScreen.kt b/app/src/main/java/moe/apex/breadboard/saucenao/ReverseSearchScreen.kt new file mode 100644 index 00000000..8d86d2cd --- /dev/null +++ b/app/src/main/java/moe/apex/breadboard/saucenao/ReverseSearchScreen.kt @@ -0,0 +1,354 @@ +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.ApiKeysSettings +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, + 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(initialImageUrl ?: "") } + var selectedFileUri by rememberSaveable { mutableStateOf(initialFileUri?.toUri()) } + + 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()) { + navController.navigate(ApiKeysSettings) + showToast(context, "Set a SauceNAO API key first.") + 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 clipboardItem = + clipboard.nativeClipboard.primaryClip?.getItemAt( + 0 + ) + val mimeType = + clipboardItem?.uri?.let { + context.contentResolver.getType(it) + } + + 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 = clipboardText + } 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..125de0f7 --- /dev/null +++ b/app/src/main/java/moe/apex/breadboard/saucenao/SauceNao.kt @@ -0,0 +1,266 @@ +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 status = headerJson.getInt("status") + val message = headerJson.optString("message").takeIf { it.isNotEmpty() } + var resultsCount = headerJson.optInt("results_returned", 0) + + if (status != 0) { + val errorMessage = message ?: "SauceNAO returned status $status" + throw SauceNaoException(errorMessage, status) + } + + val resultsJson = json.optJSONArray("results") ?: return SauceNaoResponse( + header = SauceNaoResponseHeader(status, message, resultsCount), + results = 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 + } + } ?: 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) + } +} + + +class SauceNaoException(message: String, val statusCode: Int?) : Exception(message) 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 + ) + } +} 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..f01c25d2 --- /dev/null +++ b/app/src/main/java/moe/apex/breadboard/saucenao/SauceNaoResultsScreen.kt @@ -0,0 +1,562 @@ +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.detailview.FlexibleImageGridDefaults +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() + + 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()) { + FlexibleImageGridDefaults.NoImages("No matches found :(") + } 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(key = "potentially-explicit-hidden-nag") { + 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 + .animateItem(placementSpec = bouncyAnimationSpec()) + .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/search/SearchScreen.kt b/app/src/main/java/moe/apex/breadboard/search/SearchScreen.kt index e84164b2..1ab6f3d5 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 @@ -89,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 @@ -136,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 @@ -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}") } @@ -761,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 { diff --git a/app/src/main/java/moe/apex/breadboard/social/SocialEntry.kt b/app/src/main/java/moe/apex/breadboard/social/SocialEntry.kt new file mode 100644 index 00000000..f5688930 --- /dev/null +++ b/app/src/main/java/moe/apex/breadboard/social/SocialEntry.kt @@ -0,0 +1,78 @@ +package moe.apex.breadboard.social + +import androidx.compose.runtime.Immutable +import moe.apex.breadboard.R +import androidx.core.net.toUri + + +@Immutable +data class SocialEntry( + 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")), + 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 { + 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 + } + } +} + + +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/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 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..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, @@ -35,11 +39,11 @@ 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.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 +51,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, ) ) @@ -57,8 +61,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/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/FollowingProvider.kt b/app/src/main/java/moe/apex/breadboard/util/FollowingProvider.kt new file mode 100644 index 00000000..9c8e5462 --- /dev/null +++ b/app/src/main/java/moe/apex/breadboard/util/FollowingProvider.kt @@ -0,0 +1,102 @@ +package moe.apex.breadboard.util + +import android.util.Log +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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 +import moe.apex.breadboard.viewmodel.GridStateHolderDelegate + +class FollowingProvider( + followedArtists: Set, + private val showAllRatings: Boolean, + private val initialBlockedTags: Set, +) : GridStateHolder by GridStateHolderDelegate() { + 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() + mutableFollowedArtists.addAll(artists) + } + } + + private val _images = MutableStateFlow(listOf()) + val images = _images.asStateFlow() + + 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 (followedArtists.isEmpty()) { + doneInitialLoad = true + return _images.update { emptyList() } + } + if (isLoading || !shouldKeepSearching) { + return + } + + try { + isLoading = true + Log.d("FollowingProvider", "Using Breadboard proxy (showAllRatings: $showAllRatings)") + + val results = Danbooru.loadFollowingPage( + artists = followedArtists.toList(), + page = pageNumber, + safe = !showAllRatings, + ) + + val filteredResults = results.filter { + it.metadata!!.tags.none { tag -> blockedTags.contains(tag.lowercase()) } + } + + if (results.isEmpty()) { + shouldKeepSearching = false + } else { + if (pageNumber == Danbooru.firstPageIndex) { + _images.update { filteredResults } + } else { + _images.update { current -> + current + filteredResults.filter { it !in current } + } + } + 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/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/Modifiers.kt b/app/src/main/java/moe/apex/breadboard/util/Modifiers.kt index b8cde3ec..4e348a2e 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.runtime.SideEffect 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 { + SideEffect(scrollableState.isScrollInProgress, scrollableState.lastScrolledForward) { + if (scrollableState.isScrollInProgress) { + callback(scrollableState) } } return this 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..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( @@ -23,21 +30,38 @@ 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 = 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 val index = match.groups["index"]?.value?.toIntOrNull() ?: 0 return PixivArtwork(id, index) } + 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/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/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/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/main/java/moe/apex/breadboard/util/Ui.kt b/app/src/main/java/moe/apex/breadboard/util/Ui.kt index afd67a32..6560b9b4 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 @@ -8,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 @@ -21,6 +19,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 @@ -63,6 +62,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 @@ -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 @@ -110,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 @@ -128,13 +130,16 @@ 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 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 @@ -149,6 +154,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 */ @@ -196,12 +203,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 +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 -private fun NavigationIcon(navController: NavController? = null) { +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 @@ -250,15 +275,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) ) @@ -398,6 +424,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 @@ -424,7 +463,58 @@ fun TitleSummary( trailingIcon: @Composable (() -> Unit)? = null, onClick: (() -> Unit)? = null ) { - val baseModifier = modifier.heightIn(min = 76.dp) + 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 Row( @@ -435,7 +525,8 @@ fun TitleSummary( Spacer(Modifier.width(SMALL_LARGE_SPACER.dp)) Box( modifier = Modifier - .size(48.dp) + .padding(vertical = SMALL_LARGE_SPACER.dp) + .size(leadingIconSize) .alpha(if (enabled) 1f else DISABLED_OPACITY), contentAlignment = Alignment.Center ) { @@ -443,33 +534,29 @@ 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) + title?.let { + Text( + text = it, + style = MaterialTheme.typography.prefTitle, ) } + + summary?.let { + Summary(text = it) + } } 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 ) { @@ -862,15 +949,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 ) @@ -879,7 +966,7 @@ fun HorizontalFloatingToolbarOptionalFab( modifier = modifier, colors = colors, expanded = true, - expandedShadowElevation = shadowElevation, + expandedShadowElevation = elevation, content = actions ) } @@ -891,6 +978,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 @@ -911,6 +999,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 @@ -921,6 +1018,7 @@ fun ExpressiveTagEntryContainer( text = label, fontSize = 16.sp, lineHeight = 17.sp, + maxLines = 1, overflow = TextOverflow.Ellipsis ) @@ -929,6 +1027,7 @@ fun ExpressiveTagEntryContainer( text = it, fontSize = 12.sp, lineHeight = 13.sp, + maxLines = 1, overflow = TextOverflow.Ellipsis ) } @@ -1112,12 +1211,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() @@ -1154,12 +1253,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() } } @@ -1194,11 +1293,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. */ @@ -1242,15 +1346,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 } @@ -1272,44 +1384,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) - } + onScrollToTop() } - scope.launch { - if (animate) { - uniformGridState.animateScrollToItem(0) - } else { - uniformGridState.scrollToItem(0) - } - } - alsoOnClick?.invoke() + onClick?.invoke() } ) { Icon(Icons.Rounded.KeyboardArrowUp, contentDescription = "Scroll to top") @@ -1429,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, ) @@ -1459,6 +1628,90 @@ 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 +): 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 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() + } + } +} 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..a8b7e7db --- /dev/null +++ b/app/src/main/java/moe/apex/breadboard/viewmodel/ArtistProfileViewModel.kt @@ -0,0 +1,35 @@ +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.DanbooruSafe +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 = DanbooruSafe.getMostPopularPosts(artistTag) + + _isInitialised.value = true + } +} 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..0640eaf4 --- /dev/null +++ b/app/src/main/java/moe/apex/breadboard/viewmodel/SaucenaoResultsViewModel.kt @@ -0,0 +1,112 @@ +package moe.apex.breadboard.viewmodel + +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 +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 +import moe.apex.breadboard.util.refreshImageMetadata + + +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 refreshed = refreshImageMetadata(image, auth) { newImage -> + _viewableImages.update { it + newImage } + } + if (!refreshed) { + _viewableImages.update { it + image } + } + } + } + } 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/java/moe/apex/breadboard/viewmodel/ViewModel.kt b/app/src/main/java/moe/apex/breadboard/viewmodel/ViewModel.kt index 18fcb39c..50f02309 100644 --- a/app/src/main/java/moe/apex/breadboard/viewmodel/ViewModel.kt +++ b/app/src/main/java/moe/apex/breadboard/viewmodel/ViewModel.kt @@ -13,9 +13,11 @@ 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 +import moe.apex.breadboard.preferences.BrowseTab object GlobalViewModelOwner : ViewModelStoreOwner { @@ -30,6 +32,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() @@ -42,10 +47,22 @@ 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 } + fun setFollowingProvider(provider: FollowingProvider?) { + _followingProvider.value = provider + } + + fun resetProviders() { + _recommendationsProvider.value = null + _followingProvider.value = null + } + fun setIncognito(value: Boolean) { _incognito.value = value } @@ -54,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() 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_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 @@ + + + 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 00000000..1da7c190 Binary files /dev/null and b/app/src/main/res/font/special_gothic_one_expanded.ttf differ 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") + } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ae077e37..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.00" -material3 = "1.5.0-alpha22" +compose-bom = "2026.08.00" +material3 = "1.5.0-alpha26" documentfile = "1.1.0" navigation-compose = "2.9.8" paging-compose = "3.5.0"