diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e409325f..acf81103 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -34,10 +34,6 @@ - > @Query( "SELECT * FROM film_entity " + - "WHERE updateDate BETWEEN :startAt AND :endAt " + "WHERE updateDate BETWEEN :startAt AND :endAt ", ) suspend fun loadFilm(startAt: Int, endAt: Int): List + @Query( + "SELECT * FROM film_entity " + + "WHERE updateDate BETWEEN :startAt AND :endAt " + + "ORDER BY updateDate ASC LIMIT :count OFFSET :page", + ) + suspend fun loadPagedFilm(startAt: Int, endAt: Int, page: Int, count: Int = 10): List + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertAll(filmEntityList: List) diff --git a/app/src/main/java/com/boostcamp/dailyfilm/data/calendar/CalendarDataSource.kt b/app/src/main/java/com/boostcamp/dailyfilm/data/calendar/CalendarDataSource.kt index d1caa7b3..f2fdfe72 100644 --- a/app/src/main/java/com/boostcamp/dailyfilm/data/calendar/CalendarDataSource.kt +++ b/app/src/main/java/com/boostcamp/dailyfilm/data/calendar/CalendarDataSource.kt @@ -14,6 +14,8 @@ interface CalendarDataSource { suspend fun loadFilm(startAt: Int, endAt: Int): List + suspend fun loadPagedFilm(startAt: Int, endAt: Int, page: Int): List + suspend fun insertFilm(film: FilmEntity) suspend fun insertAllFilm(filmList: List) @@ -22,7 +24,7 @@ interface CalendarDataSource { } class CalendarLocalDataSource( - private val calendarDao: CalendarDao + private val calendarDao: CalendarDao, ) : CalendarDataSource { override fun loadFilmFlow(startAt: Int, endAt: Int): Flow> = @@ -31,6 +33,9 @@ class CalendarLocalDataSource( override suspend fun loadFilm(startAt: Int, endAt: Int): List = calendarDao.loadFilm(startAt, endAt) + override suspend fun loadPagedFilm(startAt: Int, endAt: Int, page: Int): List = + calendarDao.loadPagedFilm(startAt, endAt, page) + override suspend fun insertFilm(film: FilmEntity) { calendarDao.insert(film) } diff --git a/app/src/main/java/com/boostcamp/dailyfilm/data/calendar/CalendarPagingSource.kt b/app/src/main/java/com/boostcamp/dailyfilm/data/calendar/CalendarPagingSource.kt new file mode 100644 index 00000000..642f880b --- /dev/null +++ b/app/src/main/java/com/boostcamp/dailyfilm/data/calendar/CalendarPagingSource.kt @@ -0,0 +1,39 @@ +package com.boostcamp.dailyfilm.data.calendar + +import androidx.paging.PagingSource +import androidx.paging.PagingState +import com.boostcamp.dailyfilm.data.model.DailyFilmItem + +class CalendarPagingSource( + private val startAt: Int, + private val endAt: Int, + private val calendarDataSource: CalendarDataSource, +) : PagingSource() { + override suspend fun load(params: LoadParams): LoadResult { + runCatching { + val page = params.key ?: 0 + val data = calendarDataSource.loadPagedFilm(startAt, endAt, page).filterNotNull().map { it.mapToDailyFilmItem() } + return LoadResult.Page( + data = data, + prevKey = if (page == 0) null else shouldPositive(page - PAGING_SIZE), + nextKey = if (data.isEmpty()) null else page + data.size, + ) + }.onFailure { + return LoadResult.Error(it) + } + return LoadResult.Error(Exception("Unknown Error in CalendarPagingSource")) + } + + override fun getRefreshKey(state: PagingState): Int? { + return state.anchorPosition?.let { anchorPosition -> + state.closestPageToPosition(anchorPosition)?.prevKey?.plus(PAGING_SIZE) + ?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(PAGING_SIZE) + } + } + + private fun shouldPositive(num: Int) = if (num < 0) 0 else num + + companion object { + const val PAGING_SIZE = 10 + } +} diff --git a/app/src/main/java/com/boostcamp/dailyfilm/data/calendar/CalendarRepository.kt b/app/src/main/java/com/boostcamp/dailyfilm/data/calendar/CalendarRepository.kt index f76b9ee0..f5eab19d 100644 --- a/app/src/main/java/com/boostcamp/dailyfilm/data/calendar/CalendarRepository.kt +++ b/app/src/main/java/com/boostcamp/dailyfilm/data/calendar/CalendarRepository.kt @@ -1,5 +1,9 @@ package com.boostcamp.dailyfilm.data.calendar +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import com.boostcamp.dailyfilm.data.calendar.CalendarPagingSource.Companion.PAGING_SIZE import com.boostcamp.dailyfilm.data.model.DailyFilmItem import com.boostcamp.dailyfilm.data.model.Result import kotlinx.coroutines.flow.Flow @@ -10,11 +14,13 @@ interface CalendarRepository { suspend fun loadFilm(startAt: String, endAt: String): List + suspend fun loadPagedFilm(startAt: String, endAt: String): Flow> + suspend fun deleteAllData(): Result } class CalendarRepositoryImpl( - private val calendarLocalDataSource: CalendarDataSource + private val calendarLocalDataSource: CalendarDataSource, ) : CalendarRepository { override fun loadFilmInfo(startAt: String, endAt: String): Flow> = @@ -29,6 +35,11 @@ class CalendarRepositoryImpl( filmEntity?.mapToDailyFilmItem() } + override suspend fun loadPagedFilm(startAt: String, endAt: String): Flow> = + Pager(config = PagingConfig(pageSize = PAGING_SIZE)) { + CalendarPagingSource(startAt.toInt(), endAt.toInt(), calendarLocalDataSource) + }.flow + override suspend fun deleteAllData(): Result = calendarLocalDataSource.deleteAllData() } diff --git a/app/src/main/java/com/boostcamp/dailyfilm/presentation/searchfilm/SearchFilmActivity.kt b/app/src/main/java/com/boostcamp/dailyfilm/presentation/searchfilm/SearchFilmActivity.kt deleted file mode 100644 index bd80eab3..00000000 --- a/app/src/main/java/com/boostcamp/dailyfilm/presentation/searchfilm/SearchFilmActivity.kt +++ /dev/null @@ -1,146 +0,0 @@ -package com.boostcamp.dailyfilm.presentation.searchfilm - -import android.content.Context -import android.content.Intent -import android.util.Log -import android.view.inputmethod.InputMethodManager -import android.widget.SearchView -import androidx.activity.result.ActivityResult -import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.contract.ActivityResultContracts -import androidx.activity.viewModels -import androidx.core.util.Pair -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import com.boostcamp.dailyfilm.R -import com.boostcamp.dailyfilm.databinding.ActivitySearchFilmBinding -import com.boostcamp.dailyfilm.presentation.BaseActivity -import com.boostcamp.dailyfilm.presentation.calendar.CalendarActivity -import com.boostcamp.dailyfilm.presentation.calendar.DateFragment -import com.boostcamp.dailyfilm.presentation.playfilm.PlayFilmActivity -import com.google.android.material.datepicker.MaterialDatePicker -import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch -import java.util.ArrayList - -@AndroidEntryPoint -class SearchFilmActivity : BaseActivity(R.layout.activity_search_film) { - - private val viewModel: SearchFilmViewModel by viewModels() - - private val startForResult: ActivityResultLauncher = - registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult -> - /*if (result.resultCode == Activity.RESULT_OK && result.data != null) { - val calendarIndex = result.data?.getIntExtra(DateFragment.KEY_CALENDAR_INDEX, -1) - ?: return@registerForActivityResult - val dateModel = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - result.data?.getParcelableExtra(PlayFilmFragment.KEY_DATE_MODEL, DateModel::class.java) - } else { - result.data?.getParcelableExtra(PlayFilmFragment.KEY_DATE_MODEL) - } - dateModel ?: return@registerForActivityResult - viewModel.setVideo(calendarIndex, dateModel) - reloadItem(calendarIndex, dateModel) - }*/ - } - - override fun initView() { - binding.viewModel = viewModel - - initClickEvent() - observeEvent() - handleSearchQuery() - } - - private fun initClickEvent() { - binding.barSearch.apply { - setNavigationOnClickListener { finish() } - setOnMenuItemClickListener { menuItem -> - when (menuItem.itemId) { - R.id.item_search -> true - else -> false - } - } - } - - binding.tvSearchRange.setOnClickListener { - val datePicker = MaterialDatePicker.Builder.dateRangePicker() - .setTitleText("Select Date Range") - .apply { - if (viewModel.startAt != null && viewModel.endAt != null) { - setSelection(Pair(viewModel.startAt, viewModel.endAt)) - } - } - .build() - - datePicker.apply { - addOnPositiveButtonClickListener { selection -> - viewModel.searchDateRange(selection.first, selection.second) - } - show(supportFragmentManager, TAG_DATE_PICKER) - } - } - } - - private fun observeEvent() { - lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { - viewModel.eventFlow.collectLatest { event -> - when (event) { - is SearchEvent.ItemClickEvent -> { - startForResult.launch( - Intent(this@SearchFilmActivity, PlayFilmActivity::class.java).apply { - putExtra( - DateFragment.KEY_CALENDAR_INDEX, - 0 - ) - putExtra( - DateFragment.KEY_DATE_MODEL_INDEX, - event.index - ) - putParcelableArrayListExtra( - CalendarActivity.KEY_FILM_ARRAY, - ArrayList(viewModel.itemListFlow.value.map { it?.toDateModel() }) - ) - } - ) - } - } - } - } - } - } - - private fun handleSearchQuery() { - val imm = getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - - (binding.barSearch.menu.findItem(R.id.item_search).actionView as SearchView).apply { - setOnQueryTextListener(object : SearchView.OnQueryTextListener { - override fun onQueryTextSubmit(query: String?): Boolean { - Log.d("handleIntent", "onQueryTextSubmit: $query") - - viewModel.searchKeyword(query ?: "") - return false - } - - override fun onQueryTextChange(newText: String?): Boolean { - Log.d("handleIntent", "onQueryTextChange: $newText") - return false - } - }) - - setOnCloseListener { - viewModel.searchKeyword("") - imm.hideSoftInputFromWindow(windowToken, 0) - clearFocus() - false - } - } - } - - companion object { - const val TAG_DATE_PICKER = "datePicker" - } -} diff --git a/app/src/main/java/com/boostcamp/dailyfilm/presentation/searchfilm/SearchFilmBindingAdapter.kt b/app/src/main/java/com/boostcamp/dailyfilm/presentation/searchfilm/SearchFilmBindingAdapter.kt deleted file mode 100644 index a2058802..00000000 --- a/app/src/main/java/com/boostcamp/dailyfilm/presentation/searchfilm/SearchFilmBindingAdapter.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.boostcamp.dailyfilm.presentation.searchfilm - -import android.annotation.SuppressLint -import android.widget.TextView -import androidx.databinding.BindingAdapter -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.findViewTreeLifecycleOwner -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import androidx.recyclerview.widget.RecyclerView -import com.boostcamp.dailyfilm.data.model.DailyFilmItem -import com.boostcamp.dailyfilm.presentation.searchfilm.adapter.SearchFilmAdapter -import kotlinx.coroutines.launch - -@BindingAdapter("itemList", "viewModel", requireAll = true) -fun RecyclerView.updateAdapter(itemList: List, viewModel: SearchFilmViewModel) { - if (adapter == null) { - adapter = SearchFilmAdapter { index -> - viewModel.onClickItem(index) - } - } - - findViewTreeLifecycleOwner()?.lifecycleScope?.launch { - findViewTreeLifecycleOwner()?.repeatOnLifecycle(Lifecycle.State.STARTED) { - (adapter as SearchFilmAdapter).submitList(itemList) - } - } -} - -@SuppressLint("SetTextI18n") -@BindingAdapter("startDate", "endDate", requireAll = true) -fun TextView.setSearchRange(startDate: String?, endDate: String?) { - if (startDate != null && endDate != null) { - text = "$startDate ~ $endDate" - } -} - -@SuppressLint("SetTextI18n") -@BindingAdapter("setDate", requireAll = true) -fun TextView.setDate(date: String) { - text = "${date.substring(0, 4)}년 ${date.substring(4, 6)}월 ${date.substring(6)}일" -} diff --git a/app/src/main/java/com/boostcamp/dailyfilm/presentation/searchfilm/SearchFilmComposeActivity.kt b/app/src/main/java/com/boostcamp/dailyfilm/presentation/searchfilm/SearchFilmComposeActivity.kt index f9fa7247..872001f3 100644 --- a/app/src/main/java/com/boostcamp/dailyfilm/presentation/searchfilm/SearchFilmComposeActivity.kt +++ b/app/src/main/java/com/boostcamp/dailyfilm/presentation/searchfilm/SearchFilmComposeActivity.kt @@ -7,86 +7,19 @@ import androidx.activity.result.ActivityResult import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.lazy.grid.GridCells -import androidx.compose.foundation.lazy.grid.LazyVerticalGrid -import androidx.compose.foundation.lazy.grid.itemsIndexed -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.Card -import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.Icon -import androidx.compose.material.IconButton -import androidx.compose.material.Scaffold -import androidx.compose.material.Text -import androidx.compose.material.TextField -import androidx.compose.material.TextFieldDefaults -import androidx.compose.material.TopAppBar -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowBack -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.DateRange -import androidx.compose.material.icons.filled.Search -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -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.focus.FocusManager -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalFocusManager -import androidx.compose.ui.res.colorResource -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.TextUnit -import androidx.compose.ui.unit.TextUnitType -import androidx.compose.ui.unit.dp import androidx.core.util.Pair import androidx.fragment.app.FragmentActivity import androidx.lifecycle.Lifecycle -import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle -import com.boostcamp.dailyfilm.R -import com.boostcamp.dailyfilm.data.model.DailyFilmItem import com.boostcamp.dailyfilm.presentation.calendar.CalendarActivity import com.boostcamp.dailyfilm.presentation.calendar.DateFragment import com.boostcamp.dailyfilm.presentation.playfilm.PlayFilmActivity -import com.boostcamp.dailyfilm.presentation.ui.theme.DailyFilmTheme -import com.boostcamp.dailyfilm.presentation.ui.theme.lightGray -import com.bumptech.glide.Glide -import com.bumptech.glide.RequestManager -import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi -import com.bumptech.glide.integration.compose.GlideImage -import com.bumptech.glide.load.resource.bitmap.CenterCrop -import com.bumptech.glide.load.resource.bitmap.RoundedCorners -import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions -import com.bumptech.glide.request.transition.DrawableCrossFadeFactory import com.google.android.material.datepicker.MaterialDatePicker import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -import java.text.SimpleDateFormat import java.util.ArrayList -import java.util.Locale @AndroidEntryPoint class SearchFilmComposeActivity : FragmentActivity() { @@ -95,24 +28,26 @@ class SearchFilmComposeActivity : FragmentActivity() { private val startForResult: ActivityResultLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult -> - /*if (result.resultCode == Activity.RESULT_OK && result.data != null) { - val calendarIndex = result.data?.getIntExtra(DateFragment.KEY_CALENDAR_INDEX, -1) - ?: return@registerForActivityResult + /* + if (result.resultCode == Activity.RESULT_OK && result.data != null) { val dateModel = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { result.data?.getParcelableExtra(PlayFilmFragment.KEY_DATE_MODEL, DateModel::class.java) } else { result.data?.getParcelableExtra(PlayFilmFragment.KEY_DATE_MODEL) } dateModel ?: return@registerForActivityResult + val calendarIndex = result.data?.getIntExtra(DateFragment.KEY_CALENDAR_INDEX, -1) + ?: return@registerForActivityResult viewModel.setVideo(calendarIndex, dateModel) reloadItem(calendarIndex, dateModel) - }*/ + } + */ } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { - SearchView(viewModel) + SearchScreen(viewModel) observeEvent() } } @@ -140,226 +75,34 @@ class SearchFilmComposeActivity : FragmentActivity() { }, ) } - } - } - } - } - } -} - -@OptIn( - ExperimentalFoundationApi::class, -) -@Composable -fun SearchView(viewModel: SearchFilmViewModel) { - val activity = LocalContext.current as FragmentActivity - val focusManager = LocalFocusManager.current - val requestManager = Glide.with(activity) - val factory = DrawableCrossFadeFactory.Builder().setCrossFadeEnabled(true).build() - val dottedDateFormat = SimpleDateFormat("yyyy.MM.dd", Locale.getDefault()) - val list: List by viewModel.itemListFlow.collectAsStateWithLifecycle(minActiveState = Lifecycle.State.STARTED) - - var titleVisibility by rememberSaveable { mutableStateOf(true) } - var searchText by rememberSaveable { mutableStateOf("") } - var dateRange by rememberSaveable { mutableStateOf("검색 범위를 설정하세요") } - - DailyFilmTheme { - Scaffold( - modifier = Modifier.fillMaxSize(), - topBar = { - SearchAppBar( - titleVisibility = titleVisibility, - searchText = searchText, - viewModel = viewModel, - focusManager = focusManager, - onVisibilityChange = { titleVisibility = it }, - onSearchTextChange = { searchText = it }, - onNavigationClick = { activity.finish() }, - ) - }, - ) { - Column { - SearchRangeTextBox(dateRange = dateRange) { - val datePicker = MaterialDatePicker.Builder - .dateRangePicker() - .setTitleText("Select Date Range") - .apply { - if (viewModel.startAt != null && viewModel.endAt != null) { - setSelection(Pair(viewModel.startAt, viewModel.endAt)) - } + is SearchEvent.DatePickerEvent -> { + MaterialDatePicker.Builder + .dateRangePicker() + .apply { + if (viewModel.startAt != null && viewModel.endAt != null) { + setSelection(Pair(viewModel.startAt, viewModel.endAt)) + } + } + .build() + .apply { + addOnPositiveButtonClickListener { selection -> + viewModel.searchDateRange(selection.first, selection.second) + } + show(supportFragmentManager, TAG_DATE_PICKER) + } } - .build() - datePicker.apply { - addOnPositiveButtonClickListener { selection -> - viewModel.searchDateRange(selection.first, selection.second) - dateRange = "${dottedDateFormat.format(selection.first)} ~ ${ - dottedDateFormat.format(selection.second) - }" - } - show(activity.supportFragmentManager, SearchFilmActivity.TAG_DATE_PICKER) - } - } - LazyVerticalGrid(columns = GridCells.Fixed(2), contentPadding = it) { - itemsIndexed( - items = list, - key = { i, item -> item?.videoUrl ?: i }, - ) { index, item -> - item?.let { - Row(modifier = Modifier.animateItemPlacement()) { - FilmCard(it, requestManager, factory) { viewModel.onClickItem(index) } - } + is SearchEvent.FinishEvent -> { + this@SearchFilmComposeActivity.finish() } } } } } } -} - -@Composable -fun SearchAppBar( - titleVisibility: Boolean, - searchText: String, - viewModel: SearchFilmViewModel, - focusManager: FocusManager, - onVisibilityChange: (Boolean) -> Unit, - onSearchTextChange: (String) -> Unit, - onNavigationClick: () -> Unit, -) { - TopAppBar( - title = { - if (titleVisibility) { - Text("검색") - } else { - TextField( - value = searchText, - onValueChange = onSearchTextChange, - singleLine = true, - placeholder = { Icon(Icons.Filled.Search, null, tint = Color.Gray) }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), - keyboardActions = KeyboardActions(onSearch = { - viewModel.searchKeyword(searchText) - focusManager.clearFocus() - }), - colors = TextFieldDefaults.textFieldColors( - backgroundColor = colorResource(R.color.Background), - focusedIndicatorColor = colorResource(R.color.Background), - unfocusedIndicatorColor = colorResource(R.color.Background), - ), - ) - } - }, - navigationIcon = { - IconButton(onClick = onNavigationClick) { Icon(Icons.Filled.ArrowBack, null) } - }, - actions = { - if (titleVisibility) { - IconButton( - onClick = { onVisibilityChange(false) }, - ) { Icon(Icons.Filled.Search, null) } - } else { - IconButton( - onClick = { - onVisibilityChange(true) - onSearchTextChange("") - viewModel.searchKeyword("") - }, - ) { Icon(Icons.Filled.Close, null) } - } - }, - backgroundColor = colorResource(R.color.Background), - elevation = 4.dp, - ) -} - -@Composable -fun SearchRangeTextBox(dateRange: String, onClickTextBox: () -> Unit) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(8.dp) - .background(lightGray, RoundedCornerShape(4.dp)) - .padding(0.dp, 4.dp) - .clickable { onClickTextBox() }, - ) { - Icon(Icons.Filled.DateRange, null, modifier = Modifier.padding(4.dp), tint = Color.Black) - Text( - text = dateRange, - modifier = Modifier.align(Alignment.Center), - color = Color.Black, - maxLines = 1, - style = TextStyle( - textAlign = TextAlign.Center, - fontSize = TextUnit(20F, TextUnitType.Sp), - fontWeight = FontWeight.Bold, - ), - ) - } -} -@OptIn( - ExperimentalGlideComposeApi::class, - ExperimentalMaterialApi::class, -) -@Composable -fun FilmCard( - item: DailyFilmItem, - requestManager: RequestManager, - factory: DrawableCrossFadeFactory, - onClickItem: () -> Unit, -) { - Card( - modifier = Modifier - .fillMaxWidth() - .padding(8.dp), - elevation = 4.dp, - backgroundColor = lightGray, - onClick = { onClickItem() }, - ) { - Column(modifier = Modifier.padding(8.dp)) { - GlideImage( - model = item.videoUrl, - contentDescription = "thumbnail", - modifier = Modifier - .fillMaxWidth() - .aspectRatio(3 / 4F), - contentScale = ContentScale.FillWidth, - ) { - it.thumbnail( - requestManager - .asDrawable() - .load(item.videoUrl) - .transition(DrawableTransitionOptions.withCrossFade(factory)) - .placeholder(R.color.gray) - .transform(CenterCrop(), RoundedCorners(2)), - ) - } - Text( - text = "${item.updateDate.substring(0, 4)}년 " + - "${item.updateDate.substring(4, 6)}월 " + - "${item.updateDate.substring(6)}일", - modifier = Modifier - .fillMaxWidth() - .padding(0.dp, 8.dp, 0.dp, 0.dp), - color = Color.Black, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = TextStyle(fontSize = TextUnit(16F, TextUnitType.Sp)), - ) - Text( - text = item.text, - modifier = Modifier.fillMaxWidth(), - color = Color.Gray, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } + companion object { + const val TAG_DATE_PICKER = "datePicker" } } - -@Preview(showBackground = true) -@Composable -fun DefaultPreview() { -} diff --git a/app/src/main/java/com/boostcamp/dailyfilm/presentation/searchfilm/SearchFilmViewModel.kt b/app/src/main/java/com/boostcamp/dailyfilm/presentation/searchfilm/SearchFilmViewModel.kt index b2b25fd1..221186af 100644 --- a/app/src/main/java/com/boostcamp/dailyfilm/presentation/searchfilm/SearchFilmViewModel.kt +++ b/app/src/main/java/com/boostcamp/dailyfilm/presentation/searchfilm/SearchFilmViewModel.kt @@ -2,6 +2,9 @@ package com.boostcamp.dailyfilm.presentation.searchfilm import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import androidx.paging.cachedIn +import androidx.paging.filter import com.boostcamp.dailyfilm.data.calendar.CalendarRepository import com.boostcamp.dailyfilm.data.model.DailyFilmItem import com.boostcamp.dailyfilm.data.sync.SyncRepository @@ -13,6 +16,8 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import java.text.SimpleDateFormat import java.util.* @@ -21,36 +26,34 @@ import javax.inject.Inject @HiltViewModel class SearchFilmViewModel @Inject constructor( private val calendarRepository: CalendarRepository, - private val syncRepository: SyncRepository + private val syncRepository: SyncRepository, ) : ViewModel() { private val dateFormat = SimpleDateFormat("yyyyMMdd", Locale.getDefault()) private val dottedDateFormat = SimpleDateFormat("yyyy.MM.dd", Locale.getDefault()) + private val userId = FirebaseAuth.getInstance().currentUser?.uid ?: error("Unknown User") var startAt: Long? = null private set var endAt: Long? = null private set - private val userId = FirebaseAuth.getInstance().currentUser?.uid ?: error("Unknown User") + + private val _dateRangeFlow = MutableStateFlow("검색 범위를 설정하세요") + val dateRangeFlow: StateFlow = _dateRangeFlow.asStateFlow() private val _itemListFlow = MutableStateFlow>(emptyList()) val itemListFlow: StateFlow> = _itemListFlow.asStateFlow() + private val _itemPageFlow = MutableStateFlow>(PagingData.empty()) + val itemPageFlow: StateFlow> = _itemPageFlow.asStateFlow() + private val _eventFlow = MutableSharedFlow() val eventFlow: SharedFlow = _eventFlow.asSharedFlow() - private val _startDateFlow = MutableStateFlow(null) - val startDateFlow: StateFlow = _startDateFlow.asStateFlow() - - private val _endDateFlow = MutableStateFlow(null) - val endDateFlow: StateFlow = _endDateFlow.asStateFlow() - fun searchDateRange(startAt: Long, endAt: Long) { - this.startAt = startAt - this.endAt = endAt - viewModelScope.launch { - _startDateFlow.tryEmit(dottedDateFormat.format(startAt)) - _endDateFlow.tryEmit(dottedDateFormat.format(endAt)) + this@SearchFilmViewModel.startAt = startAt + this@SearchFilmViewModel.endAt = endAt + _dateRangeFlow.emit("${dottedDateFormat.format(startAt)} ~ ${dottedDateFormat.format(endAt)}") val start = dateFormat.format(startAt) val end = dateFormat.format(endAt) @@ -67,16 +70,23 @@ class SearchFilmViewModel @Inject constructor( } _itemListFlow.emit(calendarRepository.loadFilm(start, end)) + calendarRepository.loadPagedFilm(start, end).cachedIn(viewModelScope) + .onEach { pagingData -> + _itemPageFlow.emit(pagingData) + }.launchIn(viewModelScope) } } fun searchKeyword(query: String) { viewModelScope.launch { if (startAt != null && endAt != null) { - _itemListFlow.emit( - calendarRepository.loadFilm(dateFormat.format(startAt), dateFormat.format(endAt)) - .filter { it?.text?.contains(query) ?: false } - ) + val start = dateFormat.format(startAt) + val end = dateFormat.format(endAt) + _itemListFlow.emit(calendarRepository.loadFilm(start, end).filter { it?.text?.contains(query) ?: false }) + calendarRepository.loadPagedFilm(start, end).cachedIn(viewModelScope) + .onEach { pagingData -> + _itemPageFlow.emit(pagingData.filter { it.text.contains(query) }) + }.launchIn(viewModelScope) } } } @@ -85,6 +95,14 @@ class SearchFilmViewModel @Inject constructor( event(SearchEvent.ItemClickEvent(index)) } + fun showDatePicker() { + event(SearchEvent.DatePickerEvent) + } + + fun onNavigationClick() { + event(SearchEvent.FinishEvent) + } + private fun event(event: SearchEvent) { viewModelScope.launch { _eventFlow.emit(event) @@ -94,4 +112,6 @@ class SearchFilmViewModel @Inject constructor( sealed class SearchEvent { data class ItemClickEvent(val index: Int) : SearchEvent() + object DatePickerEvent : SearchEvent() + object FinishEvent : SearchEvent() } diff --git a/app/src/main/java/com/boostcamp/dailyfilm/presentation/searchfilm/SearchScreen.kt b/app/src/main/java/com/boostcamp/dailyfilm/presentation/searchfilm/SearchScreen.kt new file mode 100644 index 00000000..48808891 --- /dev/null +++ b/app/src/main/java/com/boostcamp/dailyfilm/presentation/searchfilm/SearchScreen.kt @@ -0,0 +1,268 @@ +package com.boostcamp.dailyfilm.presentation.searchfilm + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.Card +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.TextField +import androidx.compose.material.TextFieldDefaults +import androidx.compose.material.TopAppBar +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.DateRange +import androidx.compose.material.icons.filled.Search +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.focus.FocusManager +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.TextUnitType +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.paging.compose.collectAsLazyPagingItems +import com.boostcamp.dailyfilm.R +import com.boostcamp.dailyfilm.data.model.DailyFilmItem +import com.boostcamp.dailyfilm.presentation.ui.theme.DailyFilmTheme +import com.boostcamp.dailyfilm.presentation.ui.theme.lightGray +import com.bumptech.glide.integration.compose.ExperimentalGlideComposeApi +import com.bumptech.glide.integration.compose.GlideImage + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun SearchScreen( + viewModel: SearchFilmViewModel, +) { + val focusManager = LocalFocusManager.current + val focusRequester = remember { FocusRequester() } + val lazyPagingItems = viewModel.itemPageFlow.collectAsLazyPagingItems() + val dateRange: String by viewModel.dateRangeFlow.collectAsStateWithLifecycle() + var titleVisibility by rememberSaveable { mutableStateOf(true) } + var searchText by rememberSaveable { mutableStateOf("") } + + DailyFilmTheme { + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + SearchAppBar( + titleVisibility = titleVisibility, + searchText = searchText, + viewModel = viewModel, + focusManager = focusManager, + focusRequester = focusRequester, + onVisibilityChange = { titleVisibility = it }, + onSearchTextChange = { searchText = it }, + onNavigationClick = { viewModel.onNavigationClick() }, + ) + }, + ) { + Column { + SearchRangeTextBox(dateRange = dateRange) { + viewModel.showDatePicker() + } + LazyVerticalGrid(columns = GridCells.Fixed(2), contentPadding = it) { + items( + count = lazyPagingItems.itemCount, + key = { i -> lazyPagingItems[i]?.videoUrl ?: i }, + ) { index -> + lazyPagingItems[index]?.let { + Row(modifier = Modifier.animateItemPlacement()) { + FilmCard(it) { viewModel.onClickItem(index) } + } + } + } + } + } + } + } +} + +@Composable +private fun SearchAppBar( + titleVisibility: Boolean, + searchText: String, + viewModel: SearchFilmViewModel, + focusManager: FocusManager, + focusRequester: FocusRequester, + onVisibilityChange: (Boolean) -> Unit, + onSearchTextChange: (String) -> Unit, + onNavigationClick: () -> Unit, +) { + TopAppBar( + title = { + if (titleVisibility) { + Text("검색") + } else { + TextField( + value = searchText, + onValueChange = onSearchTextChange, + singleLine = true, + placeholder = { Icon(Icons.Filled.Search, null, tint = Color.Gray) }, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { + viewModel.searchKeyword(searchText) + focusManager.clearFocus() + }), + colors = TextFieldDefaults.textFieldColors( + backgroundColor = MaterialTheme.colors.background, + focusedIndicatorColor = MaterialTheme.colors.background, + unfocusedIndicatorColor = MaterialTheme.colors.background, + ), + modifier = Modifier.focusRequester(focusRequester), + ) + } + }, + navigationIcon = { + IconButton(onClick = onNavigationClick) { Icon(Icons.Filled.ArrowBack, null) } + }, + actions = { + IconButton( + onClick = { + if (titleVisibility) { + onVisibilityChange(false) + } else { + onVisibilityChange(true) + onSearchTextChange("") + viewModel.searchKeyword("") + } + }, + ) { + if (titleVisibility) { + Icon(Icons.Filled.Search, null) + } else { + Icon(Icons.Filled.Close, null) + } + } + }, + backgroundColor = MaterialTheme.colors.background, + elevation = 4.dp, + ) + + if (titleVisibility.not()) { + SideEffect { + focusRequester.requestFocus() + } + } +} + +@Composable +private fun SearchRangeTextBox(dateRange: String, onClickTextBox: () -> Unit) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp) + .background(lightGray, RoundedCornerShape(4.dp)) + .padding(0.dp, 4.dp) + .clickable { onClickTextBox() }, + ) { + Icon(Icons.Filled.DateRange, null, modifier = Modifier.padding(4.dp), tint = Color.Black) + Text( + text = dateRange, + modifier = Modifier.align(Alignment.Center), + color = Color.Black, + maxLines = 1, + style = TextStyle( + textAlign = TextAlign.Center, + fontSize = TextUnit(20F, TextUnitType.Sp), + fontWeight = FontWeight.Bold, + ), + ) + } +} + +@OptIn( + ExperimentalGlideComposeApi::class, + ExperimentalMaterialApi::class, +) +@Composable +private fun FilmCard( + item: DailyFilmItem, + onClickItem: () -> Unit, +) { + item.updateDate.take(4) + + Card( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + elevation = 4.dp, + backgroundColor = lightGray, + onClick = { onClickItem() }, + ) { + Column(modifier = Modifier.padding(8.dp)) { + GlideImage( + model = item.videoUrl, + contentDescription = "thumbnail", + modifier = Modifier + .fillMaxWidth() + .aspectRatio(3 / 4F), + contentScale = ContentScale.FillBounds, + ) + Text( + text = item.updateDate.let { + stringResource( + R.string.card_date_format, + it.substring(0, 4), + it.substring(4, 6), + it.substring(6), + ) + }, + modifier = Modifier + .fillMaxWidth() + .padding(0.dp, 8.dp, 0.dp, 0.dp), + color = Color.Black, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = TextStyle(fontSize = TextUnit(16F, TextUnitType.Sp)), + ) + Text( + text = item.text, + modifier = Modifier.fillMaxWidth(), + color = Color.Gray, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun DefaultPreview() { +} diff --git a/app/src/main/java/com/boostcamp/dailyfilm/presentation/searchfilm/adapter/SearchFilmAdapter.kt b/app/src/main/java/com/boostcamp/dailyfilm/presentation/searchfilm/adapter/SearchFilmAdapter.kt deleted file mode 100644 index dd9312dc..00000000 --- a/app/src/main/java/com/boostcamp/dailyfilm/presentation/searchfilm/adapter/SearchFilmAdapter.kt +++ /dev/null @@ -1,55 +0,0 @@ -package com.boostcamp.dailyfilm.presentation.searchfilm.adapter - -import android.view.LayoutInflater -import android.view.ViewGroup -import androidx.databinding.DataBindingUtil -import androidx.recyclerview.widget.DiffUtil -import androidx.recyclerview.widget.ListAdapter -import androidx.recyclerview.widget.RecyclerView -import com.boostcamp.dailyfilm.R -import com.boostcamp.dailyfilm.data.model.DailyFilmItem -import com.boostcamp.dailyfilm.databinding.ItemSearchResultBinding -import com.bumptech.glide.Glide - -class SearchFilmAdapter(private val onClick: (Int) -> Unit) : ListAdapter(diffUtil) { - - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): SearchFilmViewHolder { - return SearchFilmViewHolder( - DataBindingUtil.inflate( - LayoutInflater.from(parent.context), - R.layout.item_search_result, - parent, - false - ) - ) - } - - override fun onBindViewHolder(holder: SearchFilmViewHolder, position: Int) { - holder.bind(getItem(position)) - } - - inner class SearchFilmViewHolder(private val binding: ItemSearchResultBinding) : RecyclerView.ViewHolder(binding.root) { - init { - itemView.setOnClickListener { - onClick(absoluteAdapterPosition) - } - } - - fun bind(item: DailyFilmItem) { - binding.item = item - binding.requestManager = Glide.with(itemView) - } - } - - companion object { - private val diffUtil = object : DiffUtil.ItemCallback() { - override fun areItemsTheSame(oldItem: DailyFilmItem, newItem: DailyFilmItem): Boolean { - return oldItem == newItem - } - - override fun areContentsTheSame(oldItem: DailyFilmItem, newItem: DailyFilmItem): Boolean { - return oldItem.videoUrl == newItem.videoUrl - } - } - } -} diff --git a/app/src/main/res/layout/activity_search_film.xml b/app/src/main/res/layout/activity_search_film.xml deleted file mode 100644 index 3416d982..00000000 --- a/app/src/main/res/layout/activity_search_film.xml +++ /dev/null @@ -1,77 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/layout/item_search_result.xml b/app/src/main/res/layout/item_search_result.xml deleted file mode 100644 index a2928502..00000000 --- a/app/src/main/res/layout/item_search_result.xml +++ /dev/null @@ -1,74 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9338514c..b4505143 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -54,6 +54,7 @@ 설정 화면 TotalComposeActivity Failed Google Login + %s년 %s월 %s일 sound_lottie.json 속도 조절 \ No newline at end of file