TrackerSearch.kt 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329
  1. package eu.kanade.presentation.track
  2. import androidx.compose.animation.AnimatedVisibility
  3. import androidx.compose.animation.fadeIn
  4. import androidx.compose.animation.fadeOut
  5. import androidx.compose.animation.slideInVertically
  6. import androidx.compose.animation.slideOutVertically
  7. import androidx.compose.foundation.background
  8. import androidx.compose.foundation.border
  9. import androidx.compose.foundation.layout.Arrangement
  10. import androidx.compose.foundation.layout.Box
  11. import androidx.compose.foundation.layout.Column
  12. import androidx.compose.foundation.layout.PaddingValues
  13. import androidx.compose.foundation.layout.Row
  14. import androidx.compose.foundation.layout.Spacer
  15. import androidx.compose.foundation.layout.WindowInsets
  16. import androidx.compose.foundation.layout.fillMaxWidth
  17. import androidx.compose.foundation.layout.height
  18. import androidx.compose.foundation.layout.navigationBars
  19. import androidx.compose.foundation.layout.padding
  20. import androidx.compose.foundation.layout.paddingFromBaseline
  21. import androidx.compose.foundation.layout.width
  22. import androidx.compose.foundation.layout.windowInsetsPadding
  23. import androidx.compose.foundation.lazy.items
  24. import androidx.compose.foundation.selection.selectable
  25. import androidx.compose.foundation.shape.RoundedCornerShape
  26. import androidx.compose.foundation.text.BasicTextField
  27. import androidx.compose.foundation.text.KeyboardActions
  28. import androidx.compose.foundation.text.KeyboardOptions
  29. import androidx.compose.material.icons.Icons
  30. import androidx.compose.material.icons.automirrored.outlined.ArrowBack
  31. import androidx.compose.material.icons.filled.CheckCircle
  32. import androidx.compose.material.icons.filled.Close
  33. import androidx.compose.material3.Button
  34. import androidx.compose.material3.ButtonDefaults
  35. import androidx.compose.material3.HorizontalDivider
  36. import androidx.compose.material3.Icon
  37. import androidx.compose.material3.IconButton
  38. import androidx.compose.material3.MaterialTheme
  39. import androidx.compose.material3.Text
  40. import androidx.compose.material3.TopAppBar
  41. import androidx.compose.runtime.Composable
  42. import androidx.compose.runtime.remember
  43. import androidx.compose.ui.Alignment
  44. import androidx.compose.ui.Modifier
  45. import androidx.compose.ui.draw.clip
  46. import androidx.compose.ui.focus.FocusRequester
  47. import androidx.compose.ui.focus.focusRequester
  48. import androidx.compose.ui.graphics.Color
  49. import androidx.compose.ui.graphics.SolidColor
  50. import androidx.compose.ui.platform.LocalFocusManager
  51. import androidx.compose.ui.text.capitalize
  52. import androidx.compose.ui.text.input.ImeAction
  53. import androidx.compose.ui.text.input.TextFieldValue
  54. import androidx.compose.ui.text.intl.Locale
  55. import androidx.compose.ui.text.style.TextOverflow
  56. import androidx.compose.ui.text.toLowerCase
  57. import androidx.compose.ui.tooling.preview.PreviewLightDark
  58. import androidx.compose.ui.tooling.preview.PreviewParameter
  59. import androidx.compose.ui.unit.dp
  60. import eu.kanade.presentation.manga.components.MangaCover
  61. import eu.kanade.presentation.theme.TachiyomiTheme
  62. import eu.kanade.tachiyomi.data.track.model.TrackSearch
  63. import tachiyomi.i18n.MR
  64. import tachiyomi.presentation.core.components.ScrollbarLazyColumn
  65. import tachiyomi.presentation.core.components.material.Scaffold
  66. import tachiyomi.presentation.core.components.material.padding
  67. import tachiyomi.presentation.core.i18n.localize
  68. import tachiyomi.presentation.core.screens.EmptyScreen
  69. import tachiyomi.presentation.core.screens.LoadingScreen
  70. import tachiyomi.presentation.core.util.plus
  71. import tachiyomi.presentation.core.util.runOnEnterKeyPressed
  72. import tachiyomi.presentation.core.util.secondaryItemAlpha
  73. @Composable
  74. fun TrackerSearch(
  75. query: TextFieldValue,
  76. onQueryChange: (TextFieldValue) -> Unit,
  77. onDispatchQuery: () -> Unit,
  78. queryResult: Result<List<TrackSearch>>?,
  79. selected: TrackSearch?,
  80. onSelectedChange: (TrackSearch) -> Unit,
  81. onConfirmSelection: () -> Unit,
  82. onDismissRequest: () -> Unit,
  83. ) {
  84. val focusManager = LocalFocusManager.current
  85. val focusRequester = remember { FocusRequester() }
  86. val dispatchQueryAndClearFocus: () -> Unit = {
  87. onDispatchQuery()
  88. focusManager.clearFocus()
  89. }
  90. Scaffold(
  91. topBar = {
  92. Column {
  93. TopAppBar(
  94. navigationIcon = {
  95. IconButton(onClick = onDismissRequest) {
  96. Icon(
  97. imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
  98. contentDescription = null,
  99. tint = MaterialTheme.colorScheme.onSurfaceVariant,
  100. )
  101. }
  102. },
  103. title = {
  104. BasicTextField(
  105. value = query,
  106. onValueChange = onQueryChange,
  107. modifier = Modifier
  108. .fillMaxWidth()
  109. .focusRequester(focusRequester)
  110. .runOnEnterKeyPressed(action = dispatchQueryAndClearFocus),
  111. textStyle = MaterialTheme.typography.bodyLarge
  112. .copy(color = MaterialTheme.colorScheme.onSurface),
  113. singleLine = true,
  114. keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
  115. keyboardActions = KeyboardActions(onSearch = { dispatchQueryAndClearFocus() }),
  116. cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
  117. decorationBox = {
  118. if (query.text.isEmpty()) {
  119. Text(
  120. text = localize(MR.strings.action_search_hint),
  121. color = MaterialTheme.colorScheme.onSurfaceVariant,
  122. style = MaterialTheme.typography.bodyLarge,
  123. )
  124. }
  125. it()
  126. },
  127. )
  128. },
  129. actions = {
  130. if (query.text.isNotEmpty()) {
  131. IconButton(
  132. onClick = {
  133. onQueryChange(TextFieldValue())
  134. focusRequester.requestFocus()
  135. },
  136. ) {
  137. Icon(
  138. imageVector = Icons.Default.Close,
  139. contentDescription = null,
  140. tint = MaterialTheme.colorScheme.onSurfaceVariant,
  141. )
  142. }
  143. }
  144. },
  145. )
  146. HorizontalDivider()
  147. }
  148. },
  149. bottomBar = {
  150. AnimatedVisibility(
  151. visible = selected != null,
  152. enter = fadeIn() + slideInVertically { it / 2 },
  153. exit = slideOutVertically { it / 2 } + fadeOut(),
  154. ) {
  155. Button(
  156. onClick = { onConfirmSelection() },
  157. modifier = Modifier
  158. .padding(12.dp)
  159. .windowInsetsPadding(WindowInsets.navigationBars)
  160. .fillMaxWidth(),
  161. elevation = ButtonDefaults.elevatedButtonElevation(),
  162. ) {
  163. Text(text = localize(MR.strings.action_track))
  164. }
  165. }
  166. },
  167. ) { innerPadding ->
  168. if (queryResult == null) {
  169. LoadingScreen(modifier = Modifier.padding(innerPadding))
  170. } else {
  171. val availableTracks = queryResult.getOrNull()
  172. if (availableTracks != null) {
  173. if (availableTracks.isEmpty()) {
  174. EmptyScreen(
  175. modifier = Modifier.padding(innerPadding),
  176. stringRes = MR.strings.no_results_found,
  177. )
  178. } else {
  179. ScrollbarLazyColumn(
  180. contentPadding = innerPadding + PaddingValues(vertical = 12.dp),
  181. verticalArrangement = Arrangement.spacedBy(12.dp),
  182. ) {
  183. items(
  184. items = availableTracks,
  185. key = { it.hashCode() },
  186. ) {
  187. SearchResultItem(
  188. title = it.title,
  189. coverUrl = it.cover_url,
  190. type = it.publishing_type.toLowerCase(Locale.current).capitalize(Locale.current),
  191. startDate = it.start_date,
  192. status = it.publishing_status.toLowerCase(Locale.current).capitalize(Locale.current),
  193. description = it.summary.trim(),
  194. selected = it == selected,
  195. onClick = { onSelectedChange(it) },
  196. )
  197. }
  198. }
  199. }
  200. } else {
  201. EmptyScreen(
  202. modifier = Modifier.padding(innerPadding),
  203. message = queryResult.exceptionOrNull()?.message
  204. ?: localize(MR.strings.unknown_error),
  205. )
  206. }
  207. }
  208. }
  209. }
  210. @Composable
  211. private fun SearchResultItem(
  212. title: String,
  213. coverUrl: String,
  214. type: String,
  215. startDate: String,
  216. status: String,
  217. description: String,
  218. selected: Boolean,
  219. onClick: () -> Unit,
  220. ) {
  221. val shape = RoundedCornerShape(16.dp)
  222. val borderColor = if (selected) MaterialTheme.colorScheme.outline else Color.Transparent
  223. Box(
  224. modifier = Modifier
  225. .fillMaxWidth()
  226. .padding(horizontal = 12.dp)
  227. .clip(shape)
  228. .background(MaterialTheme.colorScheme.surface)
  229. .border(
  230. width = 2.dp,
  231. color = borderColor,
  232. shape = shape,
  233. )
  234. .selectable(selected = selected, onClick = onClick)
  235. .padding(12.dp),
  236. ) {
  237. if (selected) {
  238. Icon(
  239. imageVector = Icons.Filled.CheckCircle,
  240. contentDescription = null,
  241. modifier = Modifier.align(Alignment.TopEnd),
  242. tint = MaterialTheme.colorScheme.primary,
  243. )
  244. }
  245. Column {
  246. Row {
  247. MangaCover.Book(
  248. data = coverUrl,
  249. modifier = Modifier.height(96.dp),
  250. )
  251. Spacer(modifier = Modifier.width(12.dp))
  252. Column {
  253. Text(
  254. text = title,
  255. modifier = Modifier.padding(end = 28.dp),
  256. maxLines = 2,
  257. overflow = TextOverflow.Ellipsis,
  258. style = MaterialTheme.typography.titleMedium,
  259. )
  260. if (type.isNotBlank()) {
  261. SearchResultItemDetails(
  262. title = localize(MR.strings.track_type),
  263. text = type,
  264. )
  265. }
  266. if (startDate.isNotBlank()) {
  267. SearchResultItemDetails(
  268. title = localize(MR.strings.label_started),
  269. text = startDate,
  270. )
  271. }
  272. if (status.isNotBlank()) {
  273. SearchResultItemDetails(
  274. title = localize(MR.strings.track_status),
  275. text = status,
  276. )
  277. }
  278. }
  279. }
  280. if (description.isNotBlank()) {
  281. Text(
  282. text = description,
  283. modifier = Modifier
  284. .paddingFromBaseline(top = 24.dp)
  285. .secondaryItemAlpha(),
  286. maxLines = 4,
  287. overflow = TextOverflow.Ellipsis,
  288. style = MaterialTheme.typography.bodySmall,
  289. )
  290. }
  291. }
  292. }
  293. }
  294. @Composable
  295. private fun SearchResultItemDetails(
  296. title: String,
  297. text: String,
  298. ) {
  299. Row(horizontalArrangement = Arrangement.spacedBy(MaterialTheme.padding.tiny)) {
  300. Text(
  301. text = title,
  302. maxLines = 1,
  303. style = MaterialTheme.typography.titleSmall,
  304. )
  305. Text(
  306. text = text,
  307. modifier = Modifier
  308. .weight(1f)
  309. .secondaryItemAlpha(),
  310. maxLines = 1,
  311. overflow = TextOverflow.Ellipsis,
  312. style = MaterialTheme.typography.bodyMedium,
  313. )
  314. }
  315. }
  316. @PreviewLightDark
  317. @Composable
  318. private fun TrackerSearchPreviews(
  319. @PreviewParameter(TrackerSearchPreviewProvider::class)
  320. content: @Composable () -> Unit,
  321. ) {
  322. TachiyomiTheme { content() }
  323. }