mirror of
https://github.com/louis-fri/SoMovie.git
synced 2026-03-13 09:00:47 +08:00
Bump dependencies
This commit is contained in:
@@ -32,7 +32,6 @@ dependencies {
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.lifecycle.process)
|
||||
implementation(libs.retrofit.core)
|
||||
implementation(libs.accompanist.systemuicontroller)
|
||||
implementation(libs.timber)
|
||||
|
||||
debugImplementation(libs.leakcanary)
|
||||
|
||||
@@ -3,43 +3,27 @@ package com.louisfn.somovie.app.ui.main
|
||||
import android.os.Bundle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.core.view.WindowCompat
|
||||
import com.google.accompanist.systemuicontroller.rememberSystemUiController
|
||||
import com.louisfn.somovie.ui.common.LocalAppRouter
|
||||
import com.louisfn.somovie.ui.common.LocalMoshi
|
||||
import com.louisfn.somovie.ui.common.base.BaseActivity
|
||||
import com.louisfn.somovie.ui.common.navigation.AppRouter
|
||||
import com.louisfn.somovie.ui.theme.AppTheme
|
||||
import com.squareup.moshi.Moshi
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
|
||||
@AndroidEntryPoint
|
||||
internal class MainActivity : BaseActivity() {
|
||||
|
||||
@Inject
|
||||
internal lateinit var moshi: Moshi
|
||||
|
||||
@Inject
|
||||
internal lateinit var appRouter: AppRouter
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
setContent {
|
||||
CompositionLocalProvider(
|
||||
LocalMoshi provides moshi,
|
||||
LocalAppRouter provides appRouter,
|
||||
) {
|
||||
AppTheme {
|
||||
val systemUiController = rememberSystemUiController()
|
||||
SideEffect {
|
||||
systemUiController.setSystemBarsColor(Color.Transparent)
|
||||
}
|
||||
|
||||
MainScreen()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
object AppConfig {
|
||||
const val MIN_SDK_VERSION = 26
|
||||
const val COMPILE_SDK_VERSION = 34
|
||||
const val TARGET_SDK_VERSION = 34
|
||||
const val COMPILE_SDK_VERSION = 35
|
||||
const val TARGET_SDK_VERSION = 35
|
||||
|
||||
const val VERSION_CODE = 1
|
||||
const val VERSION_NAME = "0.1.0"
|
||||
|
||||
@@ -558,6 +558,8 @@ style:
|
||||
- '10'
|
||||
- '60'
|
||||
- '24'
|
||||
- '50'
|
||||
- '100'
|
||||
- '3600'
|
||||
ignoreHashCodeFunction: true
|
||||
ignorePropertyDeclaration: true
|
||||
@@ -709,7 +711,7 @@ Compose:
|
||||
ContentEmitterReturningValues:
|
||||
active: true
|
||||
ModifierComposable:
|
||||
active: true
|
||||
active: false
|
||||
ModifierMissing:
|
||||
active: true
|
||||
ModifierReused:
|
||||
@@ -729,7 +731,7 @@ Compose:
|
||||
RememberMissing:
|
||||
active: true
|
||||
UnstableCollections:
|
||||
active: true
|
||||
active: false
|
||||
ViewModelForwarding:
|
||||
active: true
|
||||
ViewModelInjection:
|
||||
|
||||
@@ -10,5 +10,4 @@ dependencies {
|
||||
implementation(project(":feature:home:common"))
|
||||
implementation(project(":feature:login"))
|
||||
implementation(libs.androidx.lifecycle.process)
|
||||
implementation(libs.accompanist.webview)
|
||||
}
|
||||
|
||||
@@ -65,8 +65,8 @@ private fun AccountScreen(
|
||||
private fun AccountContent(
|
||||
state: AccountUiState,
|
||||
logInManager: LogInManager,
|
||||
modifier: Modifier = Modifier,
|
||||
onLogOutButtonClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
|
||||
@@ -23,10 +23,10 @@ sealed class HomeBottomSheetItem(
|
||||
@StringRes val titleId: Int,
|
||||
val icon: ImageVector,
|
||||
) {
|
||||
object Explore : HomeBottomSheetItem(ExploreNavigation, R.string.home_explore, Icons.Default.Movie)
|
||||
object WatchList : HomeBottomSheetItem(WatchlistDestination, R.string.home_watchlist, Icons.AutoMirrored.Filled.List)
|
||||
object Discover : HomeBottomSheetItem(DiscoverNavigation, R.string.home_discover, Icons.Default.Swipe)
|
||||
object Account : HomeBottomSheetItem(AccountNavigation, R.string.home_account, Icons.Default.Settings)
|
||||
data object Explore : HomeBottomSheetItem(ExploreNavigation, R.string.home_explore, Icons.Default.Movie)
|
||||
data object WatchList : HomeBottomSheetItem(WatchlistDestination, R.string.home_watchlist, Icons.AutoMirrored.Filled.List)
|
||||
data object Discover : HomeBottomSheetItem(DiscoverNavigation, R.string.home_discover, Icons.Default.Swipe)
|
||||
data object Account : HomeBottomSheetItem(AccountNavigation, R.string.home_account, Icons.Default.Settings)
|
||||
}
|
||||
|
||||
val HomeBottomSheetItems = listOf(Explore, WatchList, Discover, Account)
|
||||
|
||||
@@ -61,20 +61,20 @@ internal fun DiscoverScreen(
|
||||
|
||||
DiscoverScreen(
|
||||
uiState = uiState,
|
||||
onSwiped = viewModel::onMovieSwiped,
|
||||
onDisappeared = viewModel::onMovieDisappeared,
|
||||
onSwipe = viewModel::onMovieSwipe,
|
||||
onDisappear = viewModel::onMovieDisappeared,
|
||||
retry = { viewModel.retry() },
|
||||
onLogInSnackbarActionClicked = { showAccount() },
|
||||
onLogInSnackbarActionClick = { showAccount() },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DiscoverScreen(
|
||||
uiState: DiscoverUiState,
|
||||
onSwiped: (MovieItem, SwipeDirection) -> Unit,
|
||||
onDisappeared: (MovieItem) -> Unit,
|
||||
onSwipe: (MovieItem, SwipeDirection) -> Unit,
|
||||
onDisappear: (MovieItem) -> Unit,
|
||||
retry: () -> Unit,
|
||||
onLogInSnackbarActionClicked: () -> Unit,
|
||||
onLogInSnackbarActionClick: () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@@ -83,9 +83,9 @@ private fun DiscoverScreen(
|
||||
is DiscoverUiState.Discover -> DiscoverContent(
|
||||
items = uiState.items,
|
||||
logInSnackbarState = uiState.logInSnackbarState,
|
||||
onSwiped = onSwiped,
|
||||
onDisappeared = onDisappeared,
|
||||
onLogInSnackbarActionClicked = onLogInSnackbarActionClicked,
|
||||
onSwipe = onSwipe,
|
||||
onDisappear = onDisappear,
|
||||
onLogInSnackbarActionClick = onLogInSnackbarActionClick,
|
||||
)
|
||||
is DiscoverUiState.Retry -> Retry(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
@@ -103,20 +103,20 @@ private fun DiscoverScreen(
|
||||
private fun BoxScope.DiscoverContent(
|
||||
items: ImmutableList<MovieItem>,
|
||||
logInSnackbarState: LogInSnackbarState,
|
||||
onSwiped: (MovieItem, SwipeDirection) -> Unit,
|
||||
onDisappeared: (MovieItem) -> Unit,
|
||||
onLogInSnackbarActionClicked: () -> Unit,
|
||||
onSwipe: (MovieItem, SwipeDirection) -> Unit,
|
||||
onDisappear: (MovieItem) -> Unit,
|
||||
onLogInSnackbarActionClick: () -> Unit,
|
||||
) {
|
||||
DiscoverSwipeContainer(
|
||||
items = items,
|
||||
onSwiped = onSwiped,
|
||||
onDisappeared = onDisappeared,
|
||||
onSwipe = onSwipe,
|
||||
onDisappear = onDisappear,
|
||||
)
|
||||
if (logInSnackbarState != LogInSnackbarState.HIDDEN) {
|
||||
DefaultSnackbar(
|
||||
message = stringResource(id = commonR.string.discover_log_in_description),
|
||||
actionLabel = stringResource(id = commonR.string.discover_log_in_action),
|
||||
onActionClick = onLogInSnackbarActionClicked,
|
||||
onActionClick = onLogInSnackbarActionClick,
|
||||
modifier = Modifier
|
||||
.shake(logInSnackbarState == LogInSnackbarState.SHAKING)
|
||||
.align(Alignment.BottomCenter),
|
||||
@@ -127,8 +127,8 @@ private fun BoxScope.DiscoverContent(
|
||||
@Composable
|
||||
private fun BoxScope.DiscoverSwipeContainer(
|
||||
items: ImmutableList<MovieItem>,
|
||||
onSwiped: (MovieItem, SwipeDirection) -> Unit,
|
||||
onDisappeared: (MovieItem) -> Unit,
|
||||
onSwipe: (MovieItem, SwipeDirection) -> Unit,
|
||||
onDisappear: (MovieItem) -> Unit,
|
||||
) {
|
||||
var draggingState by remember { mutableStateOf<DraggingState?>(null) }
|
||||
|
||||
@@ -139,12 +139,12 @@ private fun BoxScope.DiscoverSwipeContainer(
|
||||
onDragging = { _, direction, ratio ->
|
||||
draggingState = DraggingState(direction, ratio)
|
||||
},
|
||||
onCanceled = { draggingState = null },
|
||||
onSwiped = { item, direction ->
|
||||
onCancel = { draggingState = null },
|
||||
onSwipe = { item, direction ->
|
||||
draggingState = null
|
||||
onSwiped(item, direction)
|
||||
onSwipe(item, direction)
|
||||
},
|
||||
onDisappeared = { item, _ -> onDisappeared(item) },
|
||||
onDisappear = { item, _ -> onDisappear(item) },
|
||||
) { item ->
|
||||
DiscoverMovieItem(item)
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ internal class DiscoverViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
@AnyThread
|
||||
fun onMovieSwiped(movieItem: MovieItem, direction: SwipeDirection) {
|
||||
fun onMovieSwipe(movieItem: MovieItem, direction: SwipeDirection) {
|
||||
viewModelScope.launch(defaultDispatcher) {
|
||||
if (direction.shouldAddMovieToWatchlist()) {
|
||||
if (!isLoggedIn.first()) {
|
||||
|
||||
@@ -60,7 +60,7 @@ class WatchListScreenTest {
|
||||
scaffoldState = rememberScaffoldState(),
|
||||
logInManager = FakeLogInManager(),
|
||||
onMovieClick = {},
|
||||
onMovieSwiped = {},
|
||||
onMovieSwipe = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -90,7 +90,7 @@ class WatchListScreenTest {
|
||||
scaffoldState = rememberScaffoldState(),
|
||||
logInManager = FakeLogInManager(),
|
||||
onMovieClick = {},
|
||||
onMovieSwiped = {},
|
||||
onMovieSwipe = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -127,7 +127,7 @@ class WatchListScreenTest {
|
||||
scaffoldState = rememberScaffoldState(),
|
||||
logInManager = FakeLogInManager(),
|
||||
onMovieClick = {},
|
||||
onMovieSwiped = {},
|
||||
onMovieSwipe = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -162,7 +162,7 @@ class WatchListScreenTest {
|
||||
scaffoldState = rememberScaffoldState(),
|
||||
logInManager = FakeLogInManager(),
|
||||
onMovieClick = {},
|
||||
onMovieSwiped = {},
|
||||
onMovieSwipe = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -201,7 +201,7 @@ class WatchListScreenTest {
|
||||
scaffoldState = rememberScaffoldState(),
|
||||
logInManager = FakeLogInManager(),
|
||||
onMovieClick = {},
|
||||
onMovieSwiped = {},
|
||||
onMovieSwipe = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -245,7 +245,7 @@ class WatchListScreenTest {
|
||||
scaffoldState = rememberScaffoldState(),
|
||||
logInManager = FakeLogInManager(),
|
||||
onMovieClick = {},
|
||||
onMovieSwiped = {},
|
||||
onMovieSwipe = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -284,7 +284,7 @@ class WatchListScreenTest {
|
||||
scaffoldState = rememberScaffoldState(),
|
||||
logInManager = FakeLogInManager(),
|
||||
onMovieClick = {},
|
||||
onMovieSwiped = {},
|
||||
onMovieSwipe = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -325,7 +325,7 @@ class WatchListScreenTest {
|
||||
scaffoldState = rememberScaffoldState(),
|
||||
logInManager = FakeLogInManager(),
|
||||
onMovieClick = {},
|
||||
onMovieSwiped = {},
|
||||
onMovieSwipe = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -362,7 +362,7 @@ class WatchListScreenTest {
|
||||
scaffoldState = rememberScaffoldState(),
|
||||
logInManager = FakeLogInManager(),
|
||||
onMovieClick = {},
|
||||
onMovieSwiped = {},
|
||||
onMovieSwipe = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -395,7 +395,7 @@ class WatchListScreenTest {
|
||||
movie = movie,
|
||||
isHidden = false,
|
||||
onClick = {},
|
||||
onSwiped = { isSwiped = true },
|
||||
onSwipe = { isSwiped = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -426,7 +426,7 @@ class WatchListScreenTest {
|
||||
movie = movie,
|
||||
isHidden = false,
|
||||
onClick = {},
|
||||
onSwiped = { isSwiped = true },
|
||||
onSwipe = { isSwiped = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -445,7 +445,7 @@ class WatchListScreenTest {
|
||||
//region Undo dismissed Snackbar
|
||||
|
||||
@Test
|
||||
fun shouldInvokedOnSnackbarActionPerformed_whenSnackbarActionClicked() {
|
||||
fun shouldInvokedonSnackbarActionPerform_whenSnackbarActionClicked() {
|
||||
// Given
|
||||
val movieId = 1L
|
||||
val action = flowOf<WatchlistAction>(ShowUndoSwipeToDismissSnackbar(movieId))
|
||||
@@ -467,9 +467,9 @@ class WatchListScreenTest {
|
||||
scaffoldState = rememberScaffoldState(),
|
||||
logInManager = FakeLogInManager(),
|
||||
onMovieClick = {},
|
||||
onMovieSwiped = {},
|
||||
onSnackbarActionPerformed = { isSnackbarActionPerformed = true },
|
||||
onSnackbarDismissed = {},
|
||||
onMovieSwipe = {},
|
||||
onSnackbarActionPerform = { isSnackbarActionPerformed = true },
|
||||
onSnackbarDismiss = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -483,7 +483,7 @@ class WatchListScreenTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shouldInvokeOnSnackbarDismissed_whenSnackbarActionClicked() {
|
||||
fun shouldInvokeonSnackbarDismiss_whenSnackbarActionClicked() {
|
||||
// Given
|
||||
val movieId = 1L
|
||||
val action = flowOf<WatchlistAction>(ShowUndoSwipeToDismissSnackbar(movieId))
|
||||
@@ -505,9 +505,9 @@ class WatchListScreenTest {
|
||||
scaffoldState = rememberScaffoldState(),
|
||||
logInManager = FakeLogInManager(),
|
||||
onMovieClick = {},
|
||||
onMovieSwiped = {},
|
||||
onSnackbarActionPerformed = {},
|
||||
onSnackbarDismissed = { isSnackbarDismissed = true },
|
||||
onMovieSwipe = {},
|
||||
onSnackbarActionPerform = {},
|
||||
onSnackbarDismiss = { isSnackbarDismissed = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
@@ -108,9 +109,9 @@ internal fun WatchlistScreen(
|
||||
scaffoldState = scaffoldState,
|
||||
logInManager = logInViewModel,
|
||||
onMovieClick = showDetails,
|
||||
onMovieSwiped = viewModel::onSwipeToDismiss,
|
||||
onSnackbarActionPerformed = viewModel::onUndoSwipeToDismissSnackbarActionPerformed,
|
||||
onSnackbarDismissed = viewModel::onUndoSwipeToDismissSnackbarDismissed,
|
||||
onMovieSwipe = viewModel::onSwipeToDismiss,
|
||||
onSnackbarActionPerform = viewModel::onUndoSwipeToDismissSnackbarActionPerformed,
|
||||
onSnackbarDismiss = viewModel::onUndoSwipeToDismissSnackbarDismissed,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -122,11 +123,14 @@ internal fun WatchlistScreen(
|
||||
scaffoldState: ScaffoldState,
|
||||
logInManager: LogInManager,
|
||||
onMovieClick: (Movie) -> Unit,
|
||||
onMovieSwiped: (Movie) -> Unit,
|
||||
onSnackbarActionPerformed: (movieId: Long) -> Unit,
|
||||
onSnackbarDismissed: (movieId: Long) -> Unit,
|
||||
onMovieSwipe: (Movie) -> Unit,
|
||||
onSnackbarActionPerform: (movieId: Long) -> Unit,
|
||||
onSnackbarDismiss: (movieId: Long) -> Unit,
|
||||
) {
|
||||
val resources = LocalContext.current.resources
|
||||
val updatedSnackbarActionPerform by rememberUpdatedState(onSnackbarActionPerform)
|
||||
val updatedSnackbarDismiss by rememberUpdatedState(onSnackbarDismiss)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
action
|
||||
.collect { action ->
|
||||
@@ -134,8 +138,8 @@ internal fun WatchlistScreen(
|
||||
is ShowUndoSwipeToDismissSnackbar ->
|
||||
scaffoldState.snackbarHostState.showCancelSwipeToDismissSnackbar(
|
||||
resources = resources,
|
||||
onSnackbarActionPerformed = { onSnackbarActionPerformed(action.movieId) },
|
||||
onSnackbarDismissed = { onSnackbarDismissed(action.movieId) },
|
||||
onSnackbarActionPerform = { updatedSnackbarActionPerform(action.movieId) },
|
||||
onSnackbarDismiss = { updatedSnackbarDismiss(action.movieId) },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -147,7 +151,7 @@ internal fun WatchlistScreen(
|
||||
scaffoldState = scaffoldState,
|
||||
logInManager = logInManager,
|
||||
onMovieClick = onMovieClick,
|
||||
onMovieSwiped = onMovieSwiped,
|
||||
onMovieSwipe = onMovieSwipe,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -158,7 +162,7 @@ internal fun WatchlistScreen(
|
||||
scaffoldState: ScaffoldState,
|
||||
logInManager: LogInManager,
|
||||
onMovieClick: (Movie) -> Unit,
|
||||
onMovieSwiped: (Movie) -> Unit,
|
||||
onMovieSwipe: (Movie) -> Unit,
|
||||
) {
|
||||
Scaffold(
|
||||
modifier = Modifier.statusBarsPadding(),
|
||||
@@ -176,7 +180,7 @@ internal fun WatchlistScreen(
|
||||
pagingItems = pagingItems,
|
||||
contentPadding = it,
|
||||
onMovieClick = onMovieClick,
|
||||
onMovieSwiped = onMovieSwiped,
|
||||
onMovieSwipe = onMovieSwipe,
|
||||
)
|
||||
|
||||
is WatchlistUiState.AccountDisconnected ->
|
||||
@@ -217,7 +221,7 @@ private fun WatchlistContent(
|
||||
pagingItems: LazyPagingItems<MovieItem>,
|
||||
contentPadding: PaddingValues,
|
||||
onMovieClick: (Movie) -> Unit,
|
||||
onMovieSwiped: (Movie) -> Unit,
|
||||
onMovieSwipe: (Movie) -> Unit,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
@@ -240,7 +244,7 @@ private fun WatchlistContent(
|
||||
loadNextPageState = uiState.loadNextPageState,
|
||||
contentPadding = contentPadding,
|
||||
onMovieClick = onMovieClick,
|
||||
onMovieSwiped = onMovieSwiped,
|
||||
onMovieSwipe = onMovieSwipe,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -252,7 +256,7 @@ private fun WatchlistLazyColumn(
|
||||
loadNextPageState: LoadNextPageState,
|
||||
contentPadding: PaddingValues,
|
||||
onMovieClick: (Movie) -> Unit,
|
||||
onMovieSwiped: (Movie) -> Unit,
|
||||
onMovieSwipe: (Movie) -> Unit,
|
||||
) {
|
||||
// https://issuetracker.google.com/issues/177245496#comment23
|
||||
if (pagingItems.itemCount == 0) return
|
||||
@@ -277,7 +281,7 @@ private fun WatchlistLazyColumn(
|
||||
movie = movieItem.movie,
|
||||
isHidden = movieItem.isHidden,
|
||||
onClick = { onMovieClick(movieItem.movie) },
|
||||
onSwiped = { onMovieSwiped(movieItem.movie) },
|
||||
onSwipe = { onMovieSwipe(movieItem.movie) },
|
||||
)
|
||||
if (!movieItem.isHidden) {
|
||||
Divider(color = MaterialTheme.colors.onBackground)
|
||||
@@ -352,12 +356,12 @@ fun WatchlistMovieItem(
|
||||
movie: Movie,
|
||||
isHidden: Boolean,
|
||||
onClick: () -> Unit,
|
||||
onSwiped: () -> Unit,
|
||||
onSwipe: () -> Unit,
|
||||
) {
|
||||
val dismissState = rememberDismissState(
|
||||
confirmStateChange = {
|
||||
if (it == DismissValue.DismissedToEnd) {
|
||||
onSwiped()
|
||||
onSwipe()
|
||||
}
|
||||
true
|
||||
},
|
||||
@@ -469,15 +473,15 @@ private fun BottomLoader(modifier: Modifier = Modifier) {
|
||||
|
||||
private suspend fun SnackbarHostState.showCancelSwipeToDismissSnackbar(
|
||||
resources: Resources,
|
||||
onSnackbarActionPerformed: () -> Unit,
|
||||
onSnackbarDismissed: () -> Unit,
|
||||
onSnackbarActionPerform: () -> Unit,
|
||||
onSnackbarDismiss: () -> Unit,
|
||||
) {
|
||||
val result = showSnackbar(
|
||||
message = resources.getString(R.string.watchlist_remove_from_watchlist_confirm_message),
|
||||
actionLabel = resources.getString(R.string.watchlist_remove_from_watchlist_action),
|
||||
)
|
||||
when (result) {
|
||||
SnackbarResult.ActionPerformed -> onSnackbarActionPerformed()
|
||||
SnackbarResult.Dismissed -> onSnackbarDismissed()
|
||||
SnackbarResult.ActionPerformed -> onSnackbarActionPerform()
|
||||
SnackbarResult.Dismissed -> onSnackbarDismiss()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,5 +7,4 @@ android {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.accompanist.webview)
|
||||
}
|
||||
|
||||
@@ -31,8 +31,8 @@ fun LogInLayout(
|
||||
modifier = modifier
|
||||
.semantics { testTag = LogInTestTag.LogInLayout },
|
||||
onLogInButtonClick = logInManager::start,
|
||||
onLogInApproved = logInManager::onApproved,
|
||||
onLogInDenied = logInManager::onDenied,
|
||||
onLogInApprove = logInManager::onApprove,
|
||||
onLogInDeny = logInManager::onDeny,
|
||||
buttonDecorator = content,
|
||||
)
|
||||
}
|
||||
@@ -42,8 +42,8 @@ private fun LogInLayout(
|
||||
uiState: LogInState,
|
||||
modifier: Modifier = Modifier,
|
||||
onLogInButtonClick: () -> Unit = {},
|
||||
onLogInApproved: () -> Unit = {},
|
||||
onLogInDenied: () -> Unit = {},
|
||||
onLogInApprove: () -> Unit = {},
|
||||
onLogInDeny: () -> Unit = {},
|
||||
buttonDecorator: @Composable BoxScope.(button: @Composable (modifier: Modifier) -> Unit) -> Unit,
|
||||
) {
|
||||
Box(modifier = modifier) {
|
||||
@@ -63,8 +63,8 @@ private fun LogInLayout(
|
||||
LogInWebView(
|
||||
uri = uiState.uri,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
onApproved = onLogInApproved,
|
||||
onDenied = onLogInDenied,
|
||||
onApprove = onLogInApprove,
|
||||
onDeny = onLogInDeny,
|
||||
)
|
||||
else -> Unit
|
||||
}
|
||||
|
||||
@@ -22,10 +22,10 @@ interface LogInManager {
|
||||
fun start()
|
||||
|
||||
@AnyThread
|
||||
fun onApproved()
|
||||
fun onApprove()
|
||||
|
||||
@AnyThread
|
||||
fun onDenied()
|
||||
fun onDeny()
|
||||
}
|
||||
|
||||
class DefaultLogInManager @Inject constructor(
|
||||
@@ -61,13 +61,13 @@ class DefaultLogInManager @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
override fun onApproved() {
|
||||
override fun onApprove() {
|
||||
scope.launch {
|
||||
logIn()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDenied() {
|
||||
override fun onDeny() {
|
||||
scope.launch {
|
||||
_state.value = LogInState.Idle
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.google.accompanist.web.WebView
|
||||
import com.google.accompanist.web.rememberWebViewState
|
||||
import com.louisfn.somovie.ui.component.WebView
|
||||
import com.louisfn.somovie.ui.component.rememberWebViewState
|
||||
|
||||
@Composable
|
||||
fun LogInWebView(
|
||||
@@ -20,8 +20,8 @@ fun LogInWebView(
|
||||
LogInWebView(
|
||||
uri = uri,
|
||||
modifier = modifier,
|
||||
onApproved = { logInManager.onApproved() },
|
||||
onDenied = { logInManager.onDenied() },
|
||||
onApprove = { logInManager.onApprove() },
|
||||
onDeny = { logInManager.onDeny() },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -30,31 +30,31 @@ fun LogInWebView(
|
||||
fun LogInWebView(
|
||||
uri: Uri,
|
||||
modifier: Modifier = Modifier,
|
||||
onApproved: () -> Unit = {},
|
||||
onDenied: () -> Unit = {},
|
||||
onApprove: () -> Unit = {},
|
||||
onDeny: () -> Unit = {},
|
||||
) {
|
||||
val state = rememberWebViewState(uri.toString())
|
||||
|
||||
val currentOnApproved by rememberUpdatedState(onApproved)
|
||||
val currentOnDenied by rememberUpdatedState(onDenied)
|
||||
val currentonApprove by rememberUpdatedState(onApprove)
|
||||
val currentonDeny by rememberUpdatedState(onDeny)
|
||||
|
||||
val currentUrl = state.lastLoadedUrl
|
||||
LaunchedEffect(key1 = currentUrl) {
|
||||
currentUrl?.run {
|
||||
when {
|
||||
contains(LogInConfig.APPROVE_PATH) -> currentOnApproved()
|
||||
contains(LogInConfig.DENY_PATH) -> currentOnDenied()
|
||||
contains(LogInConfig.APPROVE_PATH) -> currentonApprove()
|
||||
contains(LogInConfig.DENY_PATH) -> currentonDeny()
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
BackHandler(true) { currentOnDenied() }
|
||||
BackHandler(true) { currentonDeny() }
|
||||
|
||||
WebView(
|
||||
modifier = modifier,
|
||||
state = state,
|
||||
onCreated = {
|
||||
onCreate = {
|
||||
it.settings.javaScriptEnabled = true
|
||||
},
|
||||
)
|
||||
|
||||
@@ -7,5 +7,4 @@ android {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.accompanist.pager.indicators)
|
||||
}
|
||||
|
||||
@@ -39,12 +39,12 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.constraintlayout.compose.ConstraintLayout
|
||||
import androidx.constraintlayout.compose.Dimension
|
||||
import coil.compose.rememberAsyncImagePainter
|
||||
import com.google.accompanist.pager.HorizontalPagerIndicator
|
||||
import com.louisfn.somovie.domain.model.BackdropPath
|
||||
import com.louisfn.somovie.ui.common.LocalAppRouter
|
||||
import com.louisfn.somovie.ui.common.model.ImmutableList
|
||||
import com.louisfn.somovie.ui.component.AutosizeText
|
||||
import com.louisfn.somovie.ui.component.DefaultTopAppBar
|
||||
import com.louisfn.somovie.ui.component.WormPagerIndicator
|
||||
import com.louisfn.somovie.ui.component.movie.MovieVoteAverageChart
|
||||
import com.louisfn.somovie.ui.theme.Dimens
|
||||
|
||||
@@ -53,7 +53,7 @@ private const val BackdropRatio = 1.778f
|
||||
@Composable
|
||||
internal fun MovieDetailsHeader(
|
||||
headerUiState: HeaderUiState,
|
||||
onPosterPositioned: (LayoutCoordinates) -> Unit,
|
||||
onPosterPositionChange: (LayoutCoordinates) -> Unit,
|
||||
navigateUp: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -63,7 +63,7 @@ internal fun MovieDetailsHeader(
|
||||
) {
|
||||
MovieDetailsHeaderContent(
|
||||
headerUiState = headerUiState,
|
||||
onPosterPositioned = onPosterPositioned,
|
||||
onPosterPositionChange = onPosterPositionChange,
|
||||
)
|
||||
MovieDetailsTopBar(
|
||||
shareUrl = headerUiState.tmdbUrl,
|
||||
@@ -120,8 +120,8 @@ private fun MovieDetailsIconButton(onClick: () -> Unit, content: @Composable ()
|
||||
|
||||
@Composable
|
||||
private fun MovieDetailsHeaderContent(
|
||||
onPosterPositionChange: (LayoutCoordinates) -> Unit,
|
||||
headerUiState: HeaderUiState,
|
||||
onPosterPositioned: (LayoutCoordinates) -> Unit,
|
||||
) {
|
||||
ConstraintLayout(
|
||||
modifier = Modifier
|
||||
@@ -150,7 +150,7 @@ private fun MovieDetailsHeaderContent(
|
||||
bottom.linkTo(backdropsPager.bottom)
|
||||
start.linkTo(parent.start, 24.dp)
|
||||
}
|
||||
.onGloballyPositioned { onPosterPositioned(it) },
|
||||
.onGloballyPositioned { onPosterPositionChange(it) },
|
||||
)
|
||||
|
||||
AutosizeText(
|
||||
@@ -254,9 +254,8 @@ private fun BackdropsPager(
|
||||
)
|
||||
}
|
||||
|
||||
HorizontalPagerIndicator(
|
||||
WormPagerIndicator(
|
||||
pagerState = pagerState,
|
||||
pageCount = backdropPaths.size,
|
||||
activeColor = MaterialTheme.colors.onSurface,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
|
||||
@@ -41,8 +41,8 @@ import java.time.Duration
|
||||
|
||||
@Composable
|
||||
internal fun MovieDetailsScreen(
|
||||
viewModel: MovieDetailsViewModel = hiltViewModel(),
|
||||
navigateUp: () -> Unit,
|
||||
viewModel: MovieDetailsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateLifecycleAware()
|
||||
|
||||
@@ -79,7 +79,7 @@ private fun MovieDetailsScreen(
|
||||
end.linkTo(parent.end)
|
||||
},
|
||||
navigateUp = navigateUp,
|
||||
onPosterPositioned = { posterReducedCoordinates = it },
|
||||
onPosterPositionChange = { posterReducedCoordinates = it },
|
||||
)
|
||||
|
||||
Box(
|
||||
@@ -123,8 +123,8 @@ private fun MovieDetailsScreen(
|
||||
@Composable
|
||||
private fun AddToWatchlistSmallFab(
|
||||
watchlistFabState: WatchlistFabState,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Button(
|
||||
onClick = onClick,
|
||||
@@ -174,7 +174,10 @@ private fun MovieDetailsLoader(modifier: Modifier = Modifier) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MovieDetailsRetry(modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
private fun MovieDetailsRetry(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Retry(
|
||||
modifier = modifier,
|
||||
onClick = onClick,
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
package com.louisfn.somovie.feature.moviedetails.poster
|
||||
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.composed
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
|
||||
@Composable
|
||||
internal fun Modifier.draggablePoster(
|
||||
stateController: PosterStateController,
|
||||
) = composed {
|
||||
): Modifier {
|
||||
val currentStateController by rememberUpdatedState(stateController)
|
||||
|
||||
pointerInput(Unit) {
|
||||
return pointerInput(Unit) {
|
||||
detectDragGestures(
|
||||
onDrag = { change, dragAmount ->
|
||||
change.consume()
|
||||
|
||||
@@ -57,7 +57,7 @@ fun MovieDetailsPosterFullScreen(
|
||||
posterPath = posterPath,
|
||||
posterState = posterState,
|
||||
posterReducedCoordinates = posterReducedCoordinates,
|
||||
onPosterStateChanged = { posterState = it },
|
||||
onPosterStateChange = { posterState = it },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -66,12 +66,12 @@ fun MovieDetailsPosterFullScreen(
|
||||
private fun Poster(
|
||||
posterPath: PosterPath,
|
||||
posterState: MovieDetailsPosterState,
|
||||
onPosterStateChange: (MovieDetailsPosterState) -> Unit,
|
||||
posterReducedCoordinates: LayoutCoordinates,
|
||||
onPosterStateChanged: (MovieDetailsPosterState) -> Unit,
|
||||
) {
|
||||
val posterStateController = rememberPosterStateController(
|
||||
reducedCoordinates = posterReducedCoordinates,
|
||||
onStateChanged = onPosterStateChanged,
|
||||
onStateChange = onPosterStateChange,
|
||||
)
|
||||
var onLoadImageSuccess by remember { mutableStateOf(false) }
|
||||
|
||||
@@ -84,7 +84,7 @@ private fun Poster(
|
||||
.offset { posterStateController.offset.toIntOffset() }
|
||||
.clickable(withRipple = false) {
|
||||
if (onLoadImageSuccess) {
|
||||
onPosterStateChanged(posterState.toggle())
|
||||
onPosterStateChange(posterState.toggle())
|
||||
}
|
||||
}
|
||||
.draggablePoster(posterStateController),
|
||||
@@ -95,8 +95,8 @@ private fun Poster(
|
||||
@Composable
|
||||
private fun Poster(
|
||||
posterPath: PosterPath,
|
||||
modifier: Modifier = Modifier,
|
||||
onLoadImageSuccess: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val configuration = LocalConfiguration.current
|
||||
DefaultAsyncImage(
|
||||
|
||||
@@ -32,7 +32,7 @@ internal class PosterStateController(
|
||||
private val scope: CoroutineScope,
|
||||
private val minDragDistanceToReduce: Int,
|
||||
private val screenSize: Size,
|
||||
private var onStateChanged: (MovieDetailsPosterState) -> Unit,
|
||||
private var onStateChange: (MovieDetailsPosterState) -> Unit,
|
||||
) {
|
||||
private var currentState: MovieDetailsPosterState = MovieDetailsPosterState.REDUCED
|
||||
|
||||
@@ -87,7 +87,7 @@ internal class PosterStateController(
|
||||
if (currentState != MovieDetailsPosterState.EXPANDED) return
|
||||
|
||||
if (dragOffset.getDistance() > minDragDistanceToReduce) {
|
||||
onStateChanged(MovieDetailsPosterState.REDUCED)
|
||||
onStateChange(MovieDetailsPosterState.REDUCED)
|
||||
} else {
|
||||
animateToExpandedState()
|
||||
}
|
||||
@@ -110,8 +110,8 @@ internal class PosterStateController(
|
||||
|
||||
@Composable
|
||||
internal fun rememberPosterStateController(
|
||||
onStateChange: (MovieDetailsPosterState) -> Unit,
|
||||
reducedCoordinates: LayoutCoordinates,
|
||||
onStateChanged: (MovieDetailsPosterState) -> Unit,
|
||||
): PosterStateController {
|
||||
val scope = rememberCoroutineScope()
|
||||
val minDragDistanceToReduce = MinDragDistanceToReduce.roundToPx()
|
||||
@@ -122,7 +122,7 @@ internal fun rememberPosterStateController(
|
||||
minDragDistanceToReduce = minDragDistanceToReduce,
|
||||
reducedCoordinates = reducedCoordinates,
|
||||
screenSize = screenSize,
|
||||
onStateChanged = onStateChanged,
|
||||
onStateChange = onStateChange,
|
||||
scope = scope,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
[versions]
|
||||
accompanist = "0.32.0"
|
||||
androidGradlePlugin = "8.7.1"
|
||||
androidGradlePlugin = "8.7.2"
|
||||
androidxActivityCompose = "1.9.3"
|
||||
androidxAnnotation = "1.9.0"
|
||||
androidxCompose = "1.7.4"
|
||||
androidxComposeMaterial = "1.7.4"
|
||||
androidxConstraintlayout = "1.0.1"
|
||||
androidxCoreKtx = "1.13.1"
|
||||
androidxAnnotation = "1.9.1"
|
||||
androidxCompose = "1.7.5"
|
||||
androidxComposeMaterial = "1.7.5"
|
||||
androidxConstraintlayout = "1.1.0"
|
||||
androidxCoreKtx = "1.15.0"
|
||||
androidxDatastore = "1.1.1"
|
||||
androidxLifecycle = "2.8.6"
|
||||
androidxLifecycle = "2.8.7"
|
||||
androidxNavigation = "2.8.3"
|
||||
androidxPaging = "3.3.2"
|
||||
androidxPagingCompose = "3.3.2"
|
||||
@@ -17,35 +16,32 @@ androidxTest = "1.6.2"
|
||||
androidxTestCore = "1.6.1"
|
||||
androidxTestExt = "1.2.1"
|
||||
androidxWebkit = "1.12.1"
|
||||
coil = "2.5.0"
|
||||
coroutines = "1.8.1"
|
||||
detekt = "1.23.6"
|
||||
detektCompose = "0.3.3"
|
||||
coil = "2.7.0"
|
||||
coroutines = "1.9.0"
|
||||
detekt = "1.23.7"
|
||||
detektCompose = "0.4.17"
|
||||
faker = "1.16.0"
|
||||
flipper = "0.250.0"
|
||||
flipper = "0.271.0"
|
||||
gradleversions = "0.51.0"
|
||||
hilt = "2.51.1"
|
||||
hilt = "2.52"
|
||||
hiltAndroidX = "1.0.0"
|
||||
hiltCompose = "1.2.0"
|
||||
jacoco = "0.8.7"
|
||||
junit = "4.13.2"
|
||||
jvm = "1.7.20"
|
||||
kotest = "5.8.0"
|
||||
kotlin = "2.0.20"
|
||||
ksp = "2.0.20-1.0.25"
|
||||
kotest = "5.9.1"
|
||||
kotlin = "2.0.21"
|
||||
ksp = "2.0.21-1.0.26"
|
||||
ktlint = "1.0.1"
|
||||
leakcanary = "2.14"
|
||||
moshi = "1.15.1"
|
||||
okHttpLoggingInterceptor = "4.12.0"
|
||||
retrofit = "2.11.0"
|
||||
soloader = "0.11.0"
|
||||
spotless = "6.22.0"
|
||||
soloader = "0.12.1"
|
||||
spotless = "6.25.0"
|
||||
timber = "5.0.1"
|
||||
|
||||
[libraries]
|
||||
accompanist-pager-indicators = { group = "com.google.accompanist", name = "accompanist-pager-indicators", version.ref = "accompanist" }
|
||||
accompanist-systemuicontroller = { group = "com.google.accompanist", name = "accompanist-systemuicontroller", version.ref = "accompanist" }
|
||||
accompanist-webview = { group = "com.google.accompanist", name = "accompanist-webview", version.ref = "accompanist" }
|
||||
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidxActivityCompose" }
|
||||
androidx-annotation = { group = "androidx.annotation", name = "annotation", version.ref = "androidxAnnotation" }
|
||||
androidx-compose-material = { group = "androidx.compose.material", name = "material", version.ref = "androidxComposeMaterial" }
|
||||
|
||||
@@ -19,9 +19,9 @@ class FakeLogInManager : LogInManager {
|
||||
override fun start() {
|
||||
}
|
||||
|
||||
override fun onApproved() {
|
||||
override fun onApprove() {
|
||||
}
|
||||
|
||||
override fun onDenied() {
|
||||
override fun onDeny() {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,11 +2,6 @@ package com.louisfn.somovie.ui.common
|
||||
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import com.louisfn.somovie.ui.common.navigation.AppRouter
|
||||
import com.squareup.moshi.Moshi
|
||||
|
||||
val LocalMoshi = staticCompositionLocalOf<Moshi> {
|
||||
error("Moshi not provided")
|
||||
}
|
||||
|
||||
val LocalAppRouter = staticCompositionLocalOf<AppRouter> {
|
||||
error("AppRouter not provided")
|
||||
|
||||
@@ -2,20 +2,19 @@ package com.louisfn.somovie.ui.common.extension
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.composed
|
||||
|
||||
@Composable
|
||||
fun Modifier.clickable(withRipple: Boolean, onClick: () -> Unit): Modifier =
|
||||
composed {
|
||||
if (withRipple) {
|
||||
this.clickable { onClick() }
|
||||
} else {
|
||||
this.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
) {
|
||||
onClick()
|
||||
}
|
||||
if (withRipple) {
|
||||
this.clickable { onClick() }
|
||||
} else {
|
||||
this.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
) {
|
||||
onClick()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,15 +7,16 @@ import androidx.compose.animation.core.animateFloat
|
||||
import androidx.compose.animation.core.infiniteRepeatable
|
||||
import androidx.compose.animation.core.rememberInfiniteTransition
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.composed
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
|
||||
private const val DefaultInitialScale = 1f
|
||||
private const val DefaultTargetScale = .9f
|
||||
private const val DefaultAnimationDuration = 50
|
||||
|
||||
@Composable
|
||||
fun Modifier.shake(
|
||||
enabled: Boolean,
|
||||
initialScale: Float = DefaultInitialScale,
|
||||
@@ -24,22 +25,21 @@ fun Modifier.shake(
|
||||
durationMillis = DefaultAnimationDuration,
|
||||
easing = LinearEasing,
|
||||
),
|
||||
) = composed {
|
||||
if (enabled) {
|
||||
val infiniteTransition = rememberInfiniteTransition()
|
||||
val scale by infiniteTransition.animateFloat(
|
||||
initialValue = initialScale,
|
||||
targetValue = targetScale,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = animation,
|
||||
repeatMode = RepeatMode.Reverse,
|
||||
),
|
||||
)
|
||||
Modifier.graphicsLayer {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
}
|
||||
} else {
|
||||
this
|
||||
) = if (enabled) {
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "ShakeInfiniteTransition")
|
||||
val scale by infiniteTransition.animateFloat(
|
||||
initialValue = initialScale,
|
||||
targetValue = targetScale,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = animation,
|
||||
repeatMode = RepeatMode.Reverse,
|
||||
),
|
||||
label = "Shake",
|
||||
)
|
||||
graphicsLayer {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
}
|
||||
} else {
|
||||
this
|
||||
}
|
||||
|
||||
@@ -10,9 +10,9 @@ import androidx.compose.ui.Modifier
|
||||
@Composable
|
||||
fun DefaultTextButton(
|
||||
text: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
contentPadding: PaddingValues = ButtonDefaults.TextButtonContentPadding,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
androidx.compose.material.TextButton(
|
||||
modifier = modifier,
|
||||
|
||||
@@ -19,7 +19,7 @@ import androidx.compose.ui.unit.dp
|
||||
import com.louisfn.somovie.ui.common.R as commonR
|
||||
|
||||
@Composable
|
||||
fun Retry(modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
fun Retry(onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.semantics { testTag = ComponentTestTag.Retry },
|
||||
@@ -41,7 +41,7 @@ fun Retry(modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RetryButton(modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
fun RetryButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
Button(
|
||||
modifier = modifier,
|
||||
onClick = onClick,
|
||||
@@ -51,7 +51,7 @@ fun RetryButton(modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TextRetryButton(modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||
fun TextRetryButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||
DefaultTextButton(
|
||||
text = stringResource(id = commonR.string.common_retry_button),
|
||||
modifier = modifier
|
||||
|
||||
@@ -0,0 +1,726 @@
|
||||
package com.louisfn.somovie.ui.component
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.os.Bundle
|
||||
import android.view.ViewGroup.LayoutParams
|
||||
import android.webkit.WebChromeClient
|
||||
import android.webkit.WebResourceError
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebView
|
||||
import android.webkit.WebViewClient
|
||||
import android.widget.FrameLayout
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.mapSaver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import com.louisfn.somovie.ui.component.LoadingState.Finished
|
||||
import com.louisfn.somovie.ui.component.LoadingState.Loading
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/**
|
||||
* This is a fork of the accompanist library - https://google.github.io/accompanist/web
|
||||
*
|
||||
* A wrapper around the Android View WebView to provide a basic WebView composable.
|
||||
*
|
||||
* If you require more customisation you are most likely better rolling your own and using this
|
||||
* wrapper as an example.
|
||||
*
|
||||
* The WebView attempts to set the layoutParams based on the Compose modifier passed in. If it
|
||||
* is incorrectly sizing, use the layoutParams composable function instead.
|
||||
*
|
||||
* @param state The webview state holder where the Uri to load is defined.
|
||||
* @param modifier A compose modifier
|
||||
* @param captureBackPresses Set to true to have this Composable capture back presses and navigate
|
||||
* the WebView back.
|
||||
* @param navigator An optional navigator object that can be used to control the WebView's
|
||||
* navigation from outside the composable.
|
||||
* @param onCreate Called when the WebView is first created, this can be used to set additional
|
||||
* settings on the WebView. WebChromeClient and WebViewClient should not be set here as they will be
|
||||
* subsequently overwritten after this lambda is called.
|
||||
* @param onDispose Called when the WebView is destroyed. Provides a bundle which can be saved
|
||||
* if you need to save and restore state in this WebView.
|
||||
* @param client Provides access to WebViewClient via subclassing
|
||||
* @param chromeClient Provides access to WebChromeClient via subclassing
|
||||
* @param factory An optional WebView factory for using a custom subclass of WebView
|
||||
*/
|
||||
@Composable
|
||||
fun WebView(
|
||||
state: WebViewState,
|
||||
modifier: Modifier = Modifier,
|
||||
captureBackPresses: Boolean = true,
|
||||
navigator: WebViewNavigator = rememberWebViewNavigator(),
|
||||
onCreate: (WebView) -> Unit = {},
|
||||
onDispose: (WebView) -> Unit = {},
|
||||
client: AccompanistWebViewClient = remember { AccompanistWebViewClient() },
|
||||
chromeClient: AccompanistWebChromeClient = remember { AccompanistWebChromeClient() },
|
||||
factory: ((Context) -> WebView)? = null,
|
||||
) {
|
||||
BoxWithConstraints(modifier) {
|
||||
// WebView changes it's layout strategy based on
|
||||
// it's layoutParams. We convert from Compose Modifier to
|
||||
// layout params here.
|
||||
val width =
|
||||
if (constraints.hasFixedWidth)
|
||||
LayoutParams.MATCH_PARENT
|
||||
else
|
||||
LayoutParams.WRAP_CONTENT
|
||||
val height =
|
||||
if (constraints.hasFixedHeight)
|
||||
LayoutParams.MATCH_PARENT
|
||||
else
|
||||
LayoutParams.WRAP_CONTENT
|
||||
|
||||
val layoutParams = FrameLayout.LayoutParams(
|
||||
width,
|
||||
height,
|
||||
)
|
||||
|
||||
WebView(
|
||||
state,
|
||||
layoutParams,
|
||||
Modifier,
|
||||
captureBackPresses,
|
||||
navigator,
|
||||
onCreate,
|
||||
onDispose,
|
||||
client,
|
||||
chromeClient,
|
||||
factory,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A wrapper around the Android View WebView to provide a basic WebView composable.
|
||||
*
|
||||
* If you require more customisation you are most likely better rolling your own and using this
|
||||
* wrapper as an example.
|
||||
*
|
||||
* The WebView attempts to set the layoutParams based on the Compose modifier passed in. If it
|
||||
* is incorrectly sizing, use the layoutParams composable function instead.
|
||||
*
|
||||
* @param state The webview state holder where the Uri to load is defined.
|
||||
* @param layoutParams A FrameLayout.LayoutParams object to custom size the underlying WebView.
|
||||
* @param modifier A compose modifier
|
||||
* @param captureBackPresses Set to true to have this Composable capture back presses and navigate
|
||||
* the WebView back.
|
||||
* @param navigator An optional navigator object that can be used to control the WebView's
|
||||
* navigation from outside the composable.
|
||||
* @param onCreate Called when the WebView is first created, this can be used to set additional
|
||||
* settings on the WebView. WebChromeClient and WebViewClient should not be set here as they will be
|
||||
* subsequently overwritten after this lambda is called.
|
||||
* @param onDispose Called when the WebView is destroyed. Provides a bundle which can be saved
|
||||
* if you need to save and restore state in this WebView.
|
||||
* @param client Provides access to WebViewClient via subclassing
|
||||
* @param chromeClient Provides access to WebChromeClient via subclassing
|
||||
* @param factory An optional WebView factory for using a custom subclass of WebView
|
||||
*/
|
||||
@Composable
|
||||
fun WebView(
|
||||
state: WebViewState,
|
||||
layoutParams: FrameLayout.LayoutParams,
|
||||
modifier: Modifier = Modifier,
|
||||
captureBackPresses: Boolean = true,
|
||||
navigator: WebViewNavigator = rememberWebViewNavigator(),
|
||||
onCreate: (WebView) -> Unit = {},
|
||||
onDispose: (WebView) -> Unit = {},
|
||||
client: AccompanistWebViewClient = remember { AccompanistWebViewClient() },
|
||||
chromeClient: AccompanistWebChromeClient = remember { AccompanistWebChromeClient() },
|
||||
factory: ((Context) -> WebView)? = null,
|
||||
) {
|
||||
val webView = state.webView
|
||||
|
||||
BackHandler(captureBackPresses && navigator.canGoBack) {
|
||||
webView?.goBack()
|
||||
}
|
||||
|
||||
webView?.let { wv ->
|
||||
LaunchedEffect(wv, navigator) {
|
||||
with(navigator) {
|
||||
wv.handleNavigationEvents()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(wv, state) {
|
||||
snapshotFlow { state.content }.collect { content ->
|
||||
when (content) {
|
||||
is WebContent.Url -> {
|
||||
wv.loadUrl(content.url, content.additionalHttpHeaders)
|
||||
}
|
||||
|
||||
is WebContent.Data -> {
|
||||
wv.loadDataWithBaseURL(
|
||||
content.baseUrl,
|
||||
content.data,
|
||||
content.mimeType,
|
||||
content.encoding,
|
||||
content.historyUrl,
|
||||
)
|
||||
}
|
||||
|
||||
is WebContent.Post -> {
|
||||
wv.postUrl(
|
||||
content.url,
|
||||
content.postData,
|
||||
)
|
||||
}
|
||||
|
||||
is WebContent.NavigatorOnly -> {
|
||||
// NO-OP
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set the state of the client and chrome client
|
||||
// This is done internally to ensure they always are the same instance as the
|
||||
// parent Web composable
|
||||
client.state = state
|
||||
client.navigator = navigator
|
||||
chromeClient.state = state
|
||||
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
(factory?.invoke(context) ?: WebView(context)).apply {
|
||||
onCreate(this)
|
||||
|
||||
this.layoutParams = layoutParams
|
||||
|
||||
state.viewState?.let {
|
||||
this.restoreState(it)
|
||||
}
|
||||
|
||||
webChromeClient = chromeClient
|
||||
webViewClient = client
|
||||
}.also { state.webView = it }
|
||||
},
|
||||
modifier = modifier,
|
||||
onRelease = {
|
||||
onDispose(it)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* AccompanistWebViewClient
|
||||
*
|
||||
* A parent class implementation of WebViewClient that can be subclassed to add custom behaviour.
|
||||
*
|
||||
* As Accompanist Web needs to set its own web client to function, it provides this intermediary
|
||||
* class that can be overriden if further custom behaviour is required.
|
||||
*/
|
||||
open class AccompanistWebViewClient : WebViewClient() {
|
||||
open lateinit var state: WebViewState
|
||||
internal set
|
||||
open lateinit var navigator: WebViewNavigator
|
||||
internal set
|
||||
|
||||
override fun onPageStarted(view: WebView, url: String?, favicon: Bitmap?) {
|
||||
super.onPageStarted(view, url, favicon)
|
||||
state.loadingState = Loading(0.0f)
|
||||
state.errorsForCurrentRequest.clear()
|
||||
state.pageTitle = null
|
||||
state.pageIcon = null
|
||||
|
||||
state.lastLoadedUrl = url
|
||||
}
|
||||
|
||||
override fun onPageFinished(view: WebView, url: String?) {
|
||||
super.onPageFinished(view, url)
|
||||
state.loadingState = Finished
|
||||
}
|
||||
|
||||
override fun doUpdateVisitedHistory(view: WebView, url: String?, isReload: Boolean) {
|
||||
super.doUpdateVisitedHistory(view, url, isReload)
|
||||
|
||||
navigator.canGoBack = view.canGoBack()
|
||||
navigator.canGoForward = view.canGoForward()
|
||||
}
|
||||
|
||||
override fun onReceivedError(
|
||||
view: WebView,
|
||||
request: WebResourceRequest?,
|
||||
error: WebResourceError?,
|
||||
) {
|
||||
super.onReceivedError(view, request, error)
|
||||
|
||||
if (error != null) {
|
||||
state.errorsForCurrentRequest.add(WebViewError(request, error))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AccompanistWebChromeClient
|
||||
*
|
||||
* A parent class implementation of WebChromeClient that can be subclassed to add custom behaviour.
|
||||
*
|
||||
* As Accompanist Web needs to set its own web client to function, it provides this intermediary
|
||||
* class that can be overriden if further custom behaviour is required.
|
||||
*/
|
||||
open class AccompanistWebChromeClient : WebChromeClient() {
|
||||
open lateinit var state: WebViewState
|
||||
internal set
|
||||
|
||||
override fun onReceivedTitle(view: WebView, title: String?) {
|
||||
super.onReceivedTitle(view, title)
|
||||
state.pageTitle = title
|
||||
}
|
||||
|
||||
override fun onReceivedIcon(view: WebView, icon: Bitmap?) {
|
||||
super.onReceivedIcon(view, icon)
|
||||
state.pageIcon = icon
|
||||
}
|
||||
|
||||
override fun onProgressChanged(view: WebView, newProgress: Int) {
|
||||
super.onProgressChanged(view, newProgress)
|
||||
if (state.loadingState is Finished) return
|
||||
state.loadingState = Loading(newProgress / 100.0f)
|
||||
}
|
||||
}
|
||||
|
||||
sealed class WebContent {
|
||||
data class Url(
|
||||
val url: String,
|
||||
val additionalHttpHeaders: Map<String, String> = emptyMap(),
|
||||
) : WebContent()
|
||||
|
||||
data class Data(
|
||||
val data: String,
|
||||
val baseUrl: String? = null,
|
||||
val encoding: String = UTF8,
|
||||
val mimeType: String? = null,
|
||||
val historyUrl: String? = null,
|
||||
) : WebContent()
|
||||
|
||||
data class Post(
|
||||
val url: String,
|
||||
val postData: ByteArray,
|
||||
) : WebContent() {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as Post
|
||||
|
||||
if (url != other.url) return false
|
||||
if (!postData.contentEquals(other.postData)) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = url.hashCode()
|
||||
result = 31 * result + postData.contentHashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
data object NavigatorOnly : WebContent()
|
||||
}
|
||||
|
||||
/**
|
||||
* Sealed class for constraining possible loading states.
|
||||
* See [Loading] and [Finished].
|
||||
*/
|
||||
sealed class LoadingState {
|
||||
/**
|
||||
* Describes a WebView that has not yet loaded for the first time.
|
||||
*/
|
||||
data object Initializing : LoadingState()
|
||||
|
||||
/**
|
||||
* Describes a webview between `onPageStarted` and `onPageFinished` events, contains a
|
||||
* [progress] property which is updated by the webview.
|
||||
*/
|
||||
data class Loading(val progress: Float) : LoadingState()
|
||||
|
||||
/**
|
||||
* Describes a webview that has finished loading content.
|
||||
*/
|
||||
data object Finished : LoadingState()
|
||||
}
|
||||
|
||||
/**
|
||||
* A state holder to hold the state for the WebView. In most cases this will be remembered
|
||||
* using the rememberWebViewState(uri) function.
|
||||
*/
|
||||
@Stable
|
||||
class WebViewState(webContent: WebContent) {
|
||||
var lastLoadedUrl: String? by mutableStateOf(null)
|
||||
internal set
|
||||
|
||||
/**
|
||||
* The content being loaded by the WebView
|
||||
*/
|
||||
var content: WebContent by mutableStateOf(webContent)
|
||||
|
||||
/**
|
||||
* Whether the WebView is currently [LoadingState.Loading] data in its main frame (along with
|
||||
* progress) or the data loading has [LoadingState.Finished]. See [LoadingState]
|
||||
*/
|
||||
var loadingState: LoadingState by mutableStateOf(LoadingState.Initializing)
|
||||
internal set
|
||||
|
||||
/**
|
||||
* Whether the webview is currently loading data in its main frame
|
||||
*/
|
||||
val isLoading: Boolean
|
||||
get() = loadingState !is Finished
|
||||
|
||||
/**
|
||||
* The title received from the loaded content of the current page
|
||||
*/
|
||||
var pageTitle: String? by mutableStateOf(null)
|
||||
internal set
|
||||
|
||||
/**
|
||||
* the favicon received from the loaded content of the current page
|
||||
*/
|
||||
var pageIcon: Bitmap? by mutableStateOf(null)
|
||||
internal set
|
||||
|
||||
/**
|
||||
* A list for errors captured in the last load. Reset when a new page is loaded.
|
||||
* Errors could be from any resource (iframe, image, etc.), not just for the main page.
|
||||
* For more fine grained control use the OnError callback of the WebView.
|
||||
*/
|
||||
val errorsForCurrentRequest: SnapshotStateList<WebViewError> = mutableStateListOf()
|
||||
|
||||
/**
|
||||
* The saved view state from when the view was destroyed last. To restore state,
|
||||
* use the navigator and only call loadUrl if the bundle is null.
|
||||
* See WebViewSaveStateSample.
|
||||
*/
|
||||
var viewState: Bundle? = null
|
||||
internal set
|
||||
|
||||
// We need access to this in the state saver. An internal DisposableEffect or AndroidView
|
||||
// onDestroy is called after the state saver and so can't be used.
|
||||
internal var webView by mutableStateOf<WebView?>(null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows control over the navigation of a WebView from outside the composable. E.g. for performing
|
||||
* a back navigation in response to the user clicking the "up" button in a TopAppBar.
|
||||
*
|
||||
* @see [rememberWebViewNavigator]
|
||||
*/
|
||||
@Stable
|
||||
class WebViewNavigator(private val coroutineScope: CoroutineScope) {
|
||||
private sealed interface NavigationEvent {
|
||||
data object Back : NavigationEvent
|
||||
data object Forward : NavigationEvent
|
||||
data object Reload : NavigationEvent
|
||||
data object StopLoading : NavigationEvent
|
||||
|
||||
data class LoadUrl(
|
||||
val url: String,
|
||||
val additionalHttpHeaders: Map<String, String> = emptyMap(),
|
||||
) : NavigationEvent
|
||||
|
||||
data class LoadHtml(
|
||||
val html: String,
|
||||
val baseUrl: String? = null,
|
||||
val mimeType: String? = null,
|
||||
val encoding: String? = UTF8,
|
||||
val historyUrl: String? = null,
|
||||
) : NavigationEvent
|
||||
|
||||
data class PostUrl(
|
||||
val url: String,
|
||||
val postData: ByteArray,
|
||||
) : NavigationEvent {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as PostUrl
|
||||
|
||||
if (url != other.url) return false
|
||||
if (!postData.contentEquals(other.postData)) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = url.hashCode()
|
||||
result = 31 * result + postData.contentHashCode()
|
||||
return result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val navigationEvents: MutableSharedFlow<NavigationEvent> = MutableSharedFlow(replay = 1)
|
||||
|
||||
/**
|
||||
* True when the web view is able to navigate backwards, false otherwise.
|
||||
*/
|
||||
var canGoBack: Boolean by mutableStateOf(false)
|
||||
internal set
|
||||
|
||||
/**
|
||||
* True when the web view is able to navigate forwards, false otherwise.
|
||||
*/
|
||||
var canGoForward: Boolean by mutableStateOf(false)
|
||||
internal set
|
||||
|
||||
// Use Dispatchers.Main to ensure that the webview methods are called on UI thread
|
||||
internal suspend fun WebView.handleNavigationEvents(): Nothing = withContext(Dispatchers.Main) {
|
||||
navigationEvents.collect { event ->
|
||||
when (event) {
|
||||
is NavigationEvent.Back -> goBack()
|
||||
is NavigationEvent.Forward -> goForward()
|
||||
is NavigationEvent.Reload -> reload()
|
||||
is NavigationEvent.StopLoading -> stopLoading()
|
||||
is NavigationEvent.LoadHtml -> loadDataWithBaseURL(
|
||||
event.baseUrl,
|
||||
event.html,
|
||||
event.mimeType,
|
||||
event.encoding,
|
||||
event.historyUrl,
|
||||
)
|
||||
|
||||
is NavigationEvent.LoadUrl -> {
|
||||
loadUrl(event.url, event.additionalHttpHeaders)
|
||||
}
|
||||
|
||||
is NavigationEvent.PostUrl -> {
|
||||
postUrl(event.url, event.postData)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadUrl(url: String, additionalHttpHeaders: Map<String, String> = emptyMap()) {
|
||||
coroutineScope.launch {
|
||||
navigationEvents.emit(
|
||||
NavigationEvent.LoadUrl(
|
||||
url,
|
||||
additionalHttpHeaders,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadHtml(
|
||||
html: String,
|
||||
baseUrl: String? = null,
|
||||
mimeType: String? = null,
|
||||
encoding: String? = UTF8,
|
||||
historyUrl: String? = null,
|
||||
) {
|
||||
coroutineScope.launch {
|
||||
navigationEvents.emit(
|
||||
NavigationEvent.LoadHtml(
|
||||
html,
|
||||
baseUrl,
|
||||
mimeType,
|
||||
encoding,
|
||||
historyUrl,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun postUrl(
|
||||
url: String,
|
||||
postData: ByteArray,
|
||||
) {
|
||||
coroutineScope.launch {
|
||||
navigationEvents.emit(
|
||||
NavigationEvent.PostUrl(
|
||||
url,
|
||||
postData,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigates the webview back to the previous page.
|
||||
*/
|
||||
fun navigateBack() {
|
||||
coroutineScope.launch { navigationEvents.emit(NavigationEvent.Back) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigates the webview forward after going back from a page.
|
||||
*/
|
||||
fun navigateForward() {
|
||||
coroutineScope.launch { navigationEvents.emit(NavigationEvent.Forward) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Reloads the current page in the webview.
|
||||
*/
|
||||
fun reload() {
|
||||
coroutineScope.launch { navigationEvents.emit(NavigationEvent.Reload) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops the current page load (if one is loading).
|
||||
*/
|
||||
fun stopLoading() {
|
||||
coroutineScope.launch { navigationEvents.emit(NavigationEvent.StopLoading) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and remembers a [WebViewNavigator] using the default [CoroutineScope] or a provided
|
||||
* override.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberWebViewNavigator(
|
||||
coroutineScope: CoroutineScope = rememberCoroutineScope(),
|
||||
): WebViewNavigator = remember(coroutineScope) { WebViewNavigator(coroutineScope) }
|
||||
|
||||
/**
|
||||
* A wrapper class to hold errors from the WebView.
|
||||
*/
|
||||
@Immutable
|
||||
data class WebViewError(
|
||||
/**
|
||||
* The request the error came from.
|
||||
*/
|
||||
val request: WebResourceRequest?,
|
||||
/**
|
||||
* The error that was reported.
|
||||
*/
|
||||
val error: WebResourceError,
|
||||
)
|
||||
|
||||
/**
|
||||
* Creates a WebView state that is remembered across Compositions.
|
||||
*
|
||||
* @param url The url to load in the WebView
|
||||
* @param additionalHttpHeaders Optional, additional HTTP headers that are passed to [WebView.loadUrl].
|
||||
* Note that these headers are used for all subsequent requests of the WebView.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberWebViewState(
|
||||
url: String,
|
||||
additionalHttpHeaders: Map<String, String> = emptyMap(),
|
||||
): WebViewState =
|
||||
// Rather than using .apply {} here we will recreate the state, this prevents
|
||||
// a recomposition loop when the webview updates the url itself.
|
||||
remember {
|
||||
WebViewState(
|
||||
WebContent.Url(
|
||||
url = url,
|
||||
additionalHttpHeaders = additionalHttpHeaders,
|
||||
),
|
||||
)
|
||||
}.apply {
|
||||
this.content = WebContent.Url(
|
||||
url = url,
|
||||
additionalHttpHeaders = additionalHttpHeaders,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a WebView state that is remembered across Compositions.
|
||||
*
|
||||
* @param data The uri to load in the WebView
|
||||
*/
|
||||
@Composable
|
||||
fun rememberWebViewStateWithHTMLData(
|
||||
data: String,
|
||||
baseUrl: String? = null,
|
||||
encoding: String = UTF8,
|
||||
mimeType: String? = null,
|
||||
historyUrl: String? = null,
|
||||
): WebViewState =
|
||||
remember {
|
||||
WebViewState(WebContent.Data(data, baseUrl, encoding, mimeType, historyUrl))
|
||||
}.apply {
|
||||
this.content = WebContent.Data(
|
||||
data, baseUrl, encoding, mimeType, historyUrl,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a WebView state that is remembered across Compositions.
|
||||
*
|
||||
* @param url The url to load in the WebView
|
||||
* @param postData The data to be posted to the WebView with the url
|
||||
*/
|
||||
@Composable
|
||||
fun rememberWebViewState(
|
||||
url: String,
|
||||
postData: ByteArray,
|
||||
): WebViewState =
|
||||
// Rather than using .apply {} here we will recreate the state, this prevents
|
||||
// a recomposition loop when the webview updates the url itself.
|
||||
remember {
|
||||
WebViewState(
|
||||
WebContent.Post(
|
||||
url = url,
|
||||
postData = postData,
|
||||
),
|
||||
)
|
||||
}.apply {
|
||||
this.content = WebContent.Post(
|
||||
url = url,
|
||||
postData = postData,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a WebView state that is remembered across Compositions and saved
|
||||
* across activity recreation.
|
||||
* When using saved state, you cannot change the URL via recomposition. The only way to load
|
||||
* a URL is via a WebViewNavigator.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberSaveableWebViewState(): WebViewState =
|
||||
rememberSaveable(saver = WebStateSaver) {
|
||||
WebViewState(WebContent.NavigatorOnly)
|
||||
}
|
||||
|
||||
val WebStateSaver: Saver<WebViewState, Any> = run {
|
||||
val pageTitleKey = "pagetitle"
|
||||
val lastLoadedUrlKey = "lastloaded"
|
||||
val stateBundle = "bundle"
|
||||
|
||||
mapSaver(
|
||||
save = {
|
||||
val viewState = Bundle().apply { it.webView?.saveState(this) }
|
||||
mapOf(
|
||||
pageTitleKey to it.pageTitle,
|
||||
lastLoadedUrlKey to it.lastLoadedUrl,
|
||||
stateBundle to viewState,
|
||||
)
|
||||
},
|
||||
restore = {
|
||||
WebViewState(WebContent.NavigatorOnly).apply {
|
||||
this.pageTitle = it[pageTitleKey] as? String
|
||||
this.lastLoadedUrl = it[lastLoadedUrlKey] as? String
|
||||
this.viewState = it[stateBundle] as? Bundle
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private const val UTF8 = "utf-8"
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.louisfn.somovie.ui.component
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.pager.PagerState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.ContentAlpha
|
||||
import androidx.compose.material.LocalContentAlpha
|
||||
import androidx.compose.material.LocalContentColor
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawBehind
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.RoundRect
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun WormPagerIndicator(
|
||||
pagerState: PagerState,
|
||||
modifier: Modifier = Modifier,
|
||||
spacing: Dp = 8.dp,
|
||||
size: Dp = 8.dp,
|
||||
activeColor: Color = LocalContentColor.current.copy(alpha = LocalContentAlpha.current),
|
||||
color: Color = activeColor.copy(ContentAlpha.disabled),
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier,
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(spacing),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
repeat(pagerState.pageCount) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(size)
|
||||
.background(
|
||||
color = color,
|
||||
shape = CircleShape,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.wormTransition(
|
||||
pagerState,
|
||||
spacing,
|
||||
activeColor,
|
||||
)
|
||||
.size(size),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Modifier.wormTransition(
|
||||
pagerState: PagerState,
|
||||
spacing: Dp,
|
||||
color: Color = Color.White,
|
||||
) = drawBehind {
|
||||
val distance = size.width + spacing.roundToPx()
|
||||
val scrollPosition = pagerState.currentPage + pagerState.currentPageOffsetFraction
|
||||
val wormOffset = scrollPosition % 1 * 2
|
||||
|
||||
val xPos = scrollPosition.toInt() * distance
|
||||
val head = xPos + distance * 0f.coerceAtLeast(wormOffset - 1)
|
||||
val tail = xPos + size.width + 1f.coerceAtMost(wormOffset) * distance
|
||||
|
||||
val worm = RoundRect(
|
||||
left = head,
|
||||
top = 0f,
|
||||
right = tail,
|
||||
bottom = size.height,
|
||||
cornerRadius = CornerRadius(50f),
|
||||
)
|
||||
|
||||
val path = Path().apply { addRoundRect(worm) }
|
||||
drawPath(path = path, color = color)
|
||||
}
|
||||
@@ -40,9 +40,9 @@ fun <T> SwipeContainer(
|
||||
swipeAnimationSpec: AnimationSpec<Offset> = DefaultSwipeAnimationSpec,
|
||||
cancelAnimationSpec: AnimationSpec<Offset> = DefaultRewindAnimationSpec,
|
||||
onDragging: (T, SwipeDirection, ratio: Float) -> Unit = { _, _, _ -> },
|
||||
onSwiped: (T, SwipeDirection) -> Unit = { _, _ -> },
|
||||
onDisappeared: (T, SwipeDirection) -> Unit = { _, _ -> },
|
||||
onCanceled: (T) -> Unit = {},
|
||||
onSwipe: (T, SwipeDirection) -> Unit = { _, _ -> },
|
||||
onDisappear: (T, SwipeDirection) -> Unit = { _, _ -> },
|
||||
onCancel: (T) -> Unit = {},
|
||||
itemContent: @Composable (T) -> Unit,
|
||||
) {
|
||||
Box(modifier = modifier) {
|
||||
@@ -57,10 +57,10 @@ fun <T> SwipeContainer(
|
||||
velocityThreshold = velocityThreshold,
|
||||
swipeAnimationSpec = swipeAnimationSpec,
|
||||
cancelAnimationSpec = cancelAnimationSpec,
|
||||
onSwiped = { direction -> onSwiped(item, direction) },
|
||||
onSwipe = { direction -> onSwipe(item, direction) },
|
||||
onDragging = { direction, ratio -> onDragging(item, direction, ratio) },
|
||||
onCanceled = { onCanceled(item) },
|
||||
onDisappeared = { direction -> onDisappeared(item, direction) },
|
||||
onCancel = { onCancel(item) },
|
||||
onDisappear = { direction -> onDisappear(item, direction) },
|
||||
itemContent = itemContent,
|
||||
)
|
||||
}
|
||||
@@ -77,9 +77,9 @@ private fun <T> SwipeableItem(
|
||||
swipeAnimationSpec: AnimationSpec<Offset>,
|
||||
cancelAnimationSpec: AnimationSpec<Offset>,
|
||||
onDragging: (SwipeDirection, ratio: Float) -> Unit,
|
||||
onSwiped: (SwipeDirection) -> Unit,
|
||||
onDisappeared: (SwipeDirection) -> Unit,
|
||||
onCanceled: () -> Unit,
|
||||
onSwipe: (SwipeDirection) -> Unit,
|
||||
onDisappear: (SwipeDirection) -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
itemContent: @Composable (T) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
@@ -93,9 +93,9 @@ private fun <T> SwipeableItem(
|
||||
swipeAnimationSpec = swipeAnimationSpec,
|
||||
cancelAnimationSpec = cancelAnimationSpec,
|
||||
onDragging = onDragging,
|
||||
onSwiped = onSwiped,
|
||||
onDisappeared = onDisappeared,
|
||||
onCanceled = onCanceled,
|
||||
onSwipe = onSwipe,
|
||||
onDisappear = onDisappear,
|
||||
onCancel = onCancel,
|
||||
)
|
||||
|
||||
Box(
|
||||
|
||||
@@ -38,9 +38,9 @@ class SwipeController(
|
||||
private val cancelAnimationSpec: AnimationSpec<Offset>,
|
||||
private val scope: CoroutineScope,
|
||||
private val onDragging: (SwipeDirection, ratio: Float) -> Unit,
|
||||
private val onSwiped: (SwipeDirection) -> Unit,
|
||||
private val onDisappeared: (SwipeDirection) -> Unit,
|
||||
private val onCanceled: () -> Unit,
|
||||
private val onSwipe: (SwipeDirection) -> Unit,
|
||||
private val onDisappear: (SwipeDirection) -> Unit,
|
||||
private val onCancel: () -> Unit,
|
||||
) {
|
||||
|
||||
private val velocityTracker = VelocityTracker()
|
||||
@@ -79,14 +79,14 @@ class SwipeController(
|
||||
val targetOffsetX = maxItemWidthWhenRotate.withSign(offset.x)
|
||||
val direction = SwipeDirection.fromOffset(targetOffsetX)
|
||||
|
||||
onSwiped(direction)
|
||||
onSwipe(direction)
|
||||
offsetAnimatable.animateTo(
|
||||
targetValue = offset.copy(x = targetOffsetX),
|
||||
animationSpec = swipeAnimationSpec,
|
||||
)
|
||||
onDisappeared(direction)
|
||||
onDisappear(direction)
|
||||
} else {
|
||||
onCanceled()
|
||||
onCancel()
|
||||
offsetAnimatable.animateTo(Offset.Zero, cancelAnimationSpec)
|
||||
}
|
||||
}
|
||||
@@ -109,12 +109,12 @@ internal fun rememberSwipeController(
|
||||
swipeAnimationSpec: AnimationSpec<Offset>,
|
||||
cancelAnimationSpec: AnimationSpec<Offset>,
|
||||
onDragging: (SwipeDirection, ratio: Float) -> Unit,
|
||||
onSwiped: (SwipeDirection) -> Unit,
|
||||
onDisappeared: (SwipeDirection) -> Unit,
|
||||
onCanceled: () -> Unit,
|
||||
onSwipe: (SwipeDirection) -> Unit,
|
||||
onDisappear: (SwipeDirection) -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
): SwipeController {
|
||||
val currentOnDragging by rememberUpdatedState(onDragging)
|
||||
val currentOnSwipe by rememberUpdatedState(onSwiped)
|
||||
val currentOnSwipe by rememberUpdatedState(onSwipe)
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
val velocityThresholdInPx = velocityThreshold.toPx()
|
||||
@@ -135,9 +135,9 @@ internal fun rememberSwipeController(
|
||||
cancelAnimationSpec = cancelAnimationSpec,
|
||||
scope = scope,
|
||||
onDragging = currentOnDragging,
|
||||
onSwiped = currentOnSwipe,
|
||||
onDisappeared = onDisappeared,
|
||||
onCanceled = onCanceled,
|
||||
onSwipe = currentOnSwipe,
|
||||
onDisappear = onDisappear,
|
||||
onCancel = onCancel,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
package com.louisfn.somovie.ui.component.swipe
|
||||
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.composed
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
|
||||
@Composable
|
||||
internal fun Modifier.swipeableItem(
|
||||
swipeController: SwipeController,
|
||||
) = composed {
|
||||
if (!swipeController.isGestureEnabled) return@composed this
|
||||
): Modifier {
|
||||
if (!swipeController.isGestureEnabled) return this
|
||||
|
||||
val currentSwipeController by rememberUpdatedState(swipeController)
|
||||
pointerInput(Unit) {
|
||||
return pointerInput(Unit) {
|
||||
detectDragGestures(
|
||||
onDrag = { change, dragAmount ->
|
||||
change.consume()
|
||||
|
||||
Reference in New Issue
Block a user