Bump dependencies

This commit is contained in:
Louis FARIN
2024-11-03 00:20:16 +01:00
parent 30547cf037
commit a82dfd8a88
33 changed files with 1021 additions and 227 deletions

View File

@@ -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)

View File

@@ -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()
}
}

View File

@@ -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"

View File

@@ -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:

View File

@@ -10,5 +10,4 @@ dependencies {
implementation(project(":feature:home:common"))
implementation(project(":feature:login"))
implementation(libs.androidx.lifecycle.process)
implementation(libs.accompanist.webview)
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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()) {

View File

@@ -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 },
)
}
}

View File

@@ -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()
}
}

View File

@@ -7,5 +7,4 @@ android {
}
dependencies {
implementation(libs.accompanist.webview)
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
},
)

View File

@@ -7,5 +7,4 @@ android {
}
dependencies {
implementation(libs.accompanist.pager.indicators)
}

View File

@@ -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)

View File

@@ -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,

View File

@@ -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()

View File

@@ -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(

View File

@@ -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,
)
}

View File

@@ -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" }

View File

@@ -19,9 +19,9 @@ class FakeLogInManager : LogInManager {
override fun start() {
}
override fun onApproved() {
override fun onApprove() {
}
override fun onDenied() {
override fun onDeny() {
}
}

View File

@@ -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")

View File

@@ -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()
}
}

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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

View File

@@ -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"

View File

@@ -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)
}

View File

@@ -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(

View File

@@ -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,
)
}
}

View File

@@ -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()