TrackerSearch.kt 13 KB

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