diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..74a70d8 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(./gradlew assembleDebug:*)", + "Bash(./gradlew clean assembleDebug:*)", + "Bash(./gradlew kaptDebugKotlin:*)" + ] + } +} diff --git a/app/build.gradle b/app/build.gradle index 3111da0..770a00d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -70,6 +70,7 @@ dependencies { implementation 'androidx.compose.material3.adaptive:adaptive-layout' implementation 'androidx.compose.material3.adaptive:adaptive-navigation' + // Jetpack Compose BOM implementation platform("androidx.compose:compose-bom:$compose_bom_version") implementation "androidx.compose.ui:ui" @@ -78,6 +79,9 @@ dependencies { implementation "androidx.compose.material3:material3" implementation "androidx.compose.material:material-icons-extended" implementation "androidx.compose.runtime:runtime-livedata" + implementation "androidx.lifecycle:lifecycle-viewmodel-compose" + implementation 'androidx.compose.runtime:runtime-livedata' + debugImplementation "androidx.compose.ui:ui-tooling" debugImplementation "androidx.compose.ui:ui-test-manifest" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c3380e8..842a077 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -15,6 +15,7 @@ diff --git a/app/src/main/java/com/mohitb117/demo_omdb_api/activities/AppDestinations.kt b/app/src/main/java/com/mohitb117/demo_omdb_api/activities/AppDestinations.kt index d0091f6..1fae4f8 100644 --- a/app/src/main/java/com/mohitb117/demo_omdb_api/activities/AppDestinations.kt +++ b/app/src/main/java/com/mohitb117/demo_omdb_api/activities/AppDestinations.kt @@ -2,10 +2,8 @@ package com.mohitb117.demo_omdb_api.activities import androidx.annotation.StringRes import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.AccountBox import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.Home -import androidx.compose.material.icons.filled.ShoppingCart import androidx.compose.ui.graphics.vector.ImageVector import com.mohitb117.demo_omdb_api.R @@ -16,6 +14,4 @@ enum class AppDestinations( ) { HOME(R.string.home, Icons.Default.Home, R.string.home), FAVORITES(R.string.favorites, Icons.Default.Favorite, R.string.favorites), - SHOPPING(R.string.shopping, Icons.Default.ShoppingCart, R.string.shopping), - PROFILE(R.string.profile, Icons.Default.AccountBox, R.string.profile), } \ No newline at end of file diff --git a/app/src/main/java/com/mohitb117/demo_omdb_api/activities/LaunchingActivity.kt b/app/src/main/java/com/mohitb117/demo_omdb_api/activities/LaunchingActivity.kt index f0c1629..93184fb 100644 --- a/app/src/main/java/com/mohitb117/demo_omdb_api/activities/LaunchingActivity.kt +++ b/app/src/main/java/com/mohitb117/demo_omdb_api/activities/LaunchingActivity.kt @@ -1,79 +1,226 @@ package com.mohitb117.demo_omdb_api.activities -import android.graphics.Color import android.os.Bundle -import android.view.View +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import com.google.android.material.bottomnavigation.BottomNavigationView -import androidx.appcompat.app.AppCompatActivity -import androidx.core.view.ViewCompat -import androidx.core.view.WindowCompat -import androidx.core.view.WindowInsetsCompat -import androidx.core.view.insets.GradientProtection -import androidx.core.view.insets.ProtectionLayout -import androidx.navigation.NavController -import androidx.navigation.findNavController -import androidx.navigation.ui.navigateUp -import androidx.navigation.ui.AppBarConfiguration -import androidx.navigation.ui.setupActionBarWithNavController -import androidx.navigation.ui.setupWithNavController -import com.mohitb117.demo_omdb_api.IMDB_ID_KEY -import com.mohitb117.demo_omdb_api.R -import com.mohitb117.demo_omdb_api.databinding.ActivityLaunchingBinding +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.mohitb117.demo_omdb_api.activities.ui.theme.DEMO_OMDB_APITheme +import com.mohitb117.demo_omdb_api.datamodels.SearchResult +import com.mohitb117.demo_omdb_api.datamodels.SearchResultsBody +import com.mohitb117.demo_omdb_api.ui.favourites.FavouritesViewModel +import com.mohitb117.demo_omdb_api.ui.search.FavouritesComposableContent +import com.mohitb117.demo_omdb_api.ui.search.Result +import com.mohitb117.demo_omdb_api.ui.search.SearchComposableContent +import com.mohitb117.demo_omdb_api.ui.search.SearchViewModel import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.delay @AndroidEntryPoint -class LaunchingActivity : AppCompatActivity() { - - private lateinit var binding: ActivityLaunchingBinding - private lateinit var navController: NavController - private lateinit var appBarConfiguration: AppBarConfiguration +class LaunchingActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() - binding = ActivityLaunchingBinding.inflate(layoutInflater) - setContentView(binding.root) - - val bottomNavigationView: BottomNavigationView = binding.navView + setContent { + DEMO_OMDB_APITheme { + LaunchComposable(modifier = Modifier) + } + } + } - navController = findNavController(R.id.nav_host_fragment_activity_launching) + @Composable + fun LaunchComposable( + modifier: Modifier = Modifier, + searchViewModel: SearchViewModel = viewModel(), + favouritesViewModel: FavouritesViewModel = viewModel(), + ) { + val searchUiState by searchViewModel.searchResultsViewState.collectAsStateWithLifecycle() + val favouritesUiState by favouritesViewModel.favouritedIdsFlow.collectAsStateWithLifecycle() - // Passing each menu ID as a set of Ids because each - // menu should be considered as top level destinations. - appBarConfiguration = AppBarConfiguration( - setOf(R.id.navigation_search, R.id.navigation_favourites) + LaunchComposableContent( + modifier = modifier, + searchUiState = searchUiState, + favouritesUiState = favouritesUiState, + onSearch = { searchViewModel.loadSearchResult(it) }, + isMovieFavourited = { searchViewModel.favouritedIds.value.contains(it) }, + onToggleMovieFavorited = { searchViewModel.toggleFavourite(it) }, ) + } - setupActionBarWithNavController(navController, appBarConfiguration) + @Composable + fun LaunchComposableContent( + modifier: Modifier = Modifier, + searchUiState: Result, + favouritesUiState: Set, + onSearch: (String) -> Unit, + isMovieFavourited: (SearchResult) -> Boolean = { false }, + onToggleMovieFavorited: suspend (SearchResult) -> Boolean = { false }, + ) { + var currentDestination by rememberSaveable { mutableStateOf(AppDestinations.HOME) } - bottomNavigationView.setupWithNavController(navController) + NavigationSuiteScaffold( + navigationSuiteItems = { + AppDestinations.entries.forEach { + item( + icon = { + Icon(it.icon, stringResource(it.contentDescription)) + }, + modifier = Modifier, + label = { Text(stringResource(it.label)) }, + selected = it == currentDestination, + onClick = { currentDestination = it }, + ) + } + } + ) { + var searchQuery by rememberSaveable { mutableStateOf("") } - ViewCompat.setOnApplyWindowInsetsListener(binding.root) { v: View, insets: WindowInsetsCompat -> - val innerPadding = insets.getInsets( - WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout() - ) + Scaffold( + modifier = Modifier.fillMaxSize(), + bottomBar = { + if (currentDestination == AppDestinations.HOME) { + OutlinedTextField( + modifier = Modifier + .fillMaxWidth() + .background(color = Color.Gray), + value = searchQuery, + onValueChange = { searchQuery = it }, + label = { Text(text = "Search Movie!") } + ) + } + } + ) { innerPadding -> + when (currentDestination) { + AppDestinations.HOME -> { + SearchMovieInfoLayout( + name = stringResource(currentDestination.label), + modifier = Modifier.padding(innerPadding), + searchQuery = searchQuery, + searchUiState = searchUiState, + onSearch = onSearch, + isMovieFavourited = isMovieFavourited, + onToggleMovieFavorited = onToggleMovieFavorited, + ) + } + + else -> { + FavouritesComposableLayout( + name = stringResource(currentDestination.label), + modifier = Modifier.padding(innerPadding), + uiState = favouritesUiState, + isMovieFavourited = isMovieFavourited, + onToggleMovieFavorited = onToggleMovieFavorited, + ) + } + } + } + } + } + + @Composable + fun SearchMovieInfoLayout( + name: String, + modifier: Modifier = Modifier, + searchQuery: String = "", + searchUiState: Result, + onSearch: (String) -> Unit, + isMovieFavourited: (SearchResult) -> Boolean = { false }, + onToggleMovieFavorited: suspend (SearchResult) -> Boolean = { false }, + ) { + Column( + modifier = modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { - v.setPadding( - innerPadding.left, - innerPadding.top, - innerPadding.right, - innerPadding.bottom + if (searchQuery.isNotEmpty()) { + LaunchedEffect(searchQuery) { + delay(500) // Debounce for 500ms + onSearch(searchQuery) + } + + SearchComposableContent( + modifier = Modifier, + searchUiState = searchUiState, + onToggleMovieFavorited = onToggleMovieFavorited, + isMovieFavourited = isMovieFavourited, + ) + } else { + Text("Nothing to search right now!") + } + } + } + + @Composable + fun FavouritesComposableLayout( + name: String, + uiState: Set, + modifier: Modifier = Modifier, + isMovieFavourited: (SearchResult) -> Boolean = { false }, + onToggleMovieFavorited: suspend (SearchResult) -> Boolean = { false }, + ) { + Column( + modifier = modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + + FavouritesComposableContent( + modifier = Modifier, + uiState = uiState, + onToggleMovieFavorited = onToggleMovieFavorited, + isMovieFavourited = isMovieFavourited, ) - insets } } - fun gotoDetails(imdbId: String) { - navController.navigate( - R.id.navigation_details, - Bundle().apply { putString(IMDB_ID_KEY, imdbId) } - ) + @Preview(showBackground = true) + @Composable + fun SearchMovieInfoLayoutPreview() { + DEMO_OMDB_APITheme { + SearchMovieInfoLayout( + name = "Android", + searchUiState = Result.Loading(""), + onSearch = {} + ) + } } - override fun onSupportNavigateUp(): Boolean { - return navController.navigateUp(appBarConfiguration) + @Preview(showBackground = true) + @Composable + fun LaunchComposablePreview() { + DEMO_OMDB_APITheme { + LaunchComposableContent( + searchUiState = Result.Loading(""), + favouritesUiState = emptySet(), + onSearch = {} + ) + } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/mohitb117/demo_omdb_api/activities/ui/theme/Color.kt b/app/src/main/java/com/mohitb117/demo_omdb_api/activities/ui/theme/Color.kt new file mode 100644 index 0000000..b054269 --- /dev/null +++ b/app/src/main/java/com/mohitb117/demo_omdb_api/activities/ui/theme/Color.kt @@ -0,0 +1,11 @@ +package com.mohitb117.demo_omdb_api.activities.ui.theme + +import androidx.compose.ui.graphics.Color + +val Purple80 = Color(0xFFD0BCFF) +val PurpleGrey80 = Color(0xFFCCC2DC) +val Pink80 = Color(0xFFEFB8C8) + +val Purple40 = Color(0xFF6650a4) +val PurpleGrey40 = Color(0xFF625b71) +val Pink40 = Color(0xFF7D5260) \ No newline at end of file diff --git a/app/src/main/java/com/mohitb117/demo_omdb_api/activities/ui/theme/Theme.kt b/app/src/main/java/com/mohitb117/demo_omdb_api/activities/ui/theme/Theme.kt new file mode 100644 index 0000000..6a7efc1 --- /dev/null +++ b/app/src/main/java/com/mohitb117/demo_omdb_api/activities/ui/theme/Theme.kt @@ -0,0 +1,58 @@ +package com.mohitb117.demo_omdb_api.activities.ui.theme + +import android.app.Activity +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.dynamicDarkColorScheme +import androidx.compose.material3.dynamicLightColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext + +private val DarkColorScheme = darkColorScheme( + primary = Purple80, + secondary = PurpleGrey80, + tertiary = Pink80 +) + +private val LightColorScheme = lightColorScheme( + primary = Purple40, + secondary = PurpleGrey40, + tertiary = Pink40 + + /* Other default colors to override + background = Color(0xFFFFFBFE), + surface = Color(0xFFFFFBFE), + onPrimary = Color.White, + onSecondary = Color.White, + onTertiary = Color.White, + onBackground = Color(0xFF1C1B1F), + onSurface = Color(0xFF1C1B1F), + */ +) + +@Composable +fun DEMO_OMDB_APITheme( + darkTheme: Boolean = isSystemInDarkTheme(), + // Dynamic color is available on Android 12+ + dynamicColor: Boolean = true, + content: @Composable () -> Unit +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + + darkTheme -> DarkColorScheme + else -> LightColorScheme + } + + MaterialTheme( + colorScheme = colorScheme, + typography = Typography, + content = content + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/mohitb117/demo_omdb_api/activities/ui/theme/Type.kt b/app/src/main/java/com/mohitb117/demo_omdb_api/activities/ui/theme/Type.kt new file mode 100644 index 0000000..ec7b1f5 --- /dev/null +++ b/app/src/main/java/com/mohitb117/demo_omdb_api/activities/ui/theme/Type.kt @@ -0,0 +1,34 @@ +package com.mohitb117.demo_omdb_api.activities.ui.theme + +import androidx.compose.material3.Typography +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp + +// Set of Material typography styles to start with +val Typography = Typography( + bodyLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 16.sp, + lineHeight = 24.sp, + letterSpacing = 0.5.sp + ) + /* Other default text styles to override + titleLarge = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Normal, + fontSize = 22.sp, + lineHeight = 28.sp, + letterSpacing = 0.sp + ), + labelSmall = TextStyle( + fontFamily = FontFamily.Default, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + lineHeight = 16.sp, + letterSpacing = 0.5.sp + ) + */ +) \ No newline at end of file diff --git a/app/src/main/java/com/mohitb117/demo_omdb_api/datamodels/SearchResult.kt b/app/src/main/java/com/mohitb117/demo_omdb_api/datamodels/SearchResult.kt index 153a589..b5285d7 100644 --- a/app/src/main/java/com/mohitb117/demo_omdb_api/datamodels/SearchResult.kt +++ b/app/src/main/java/com/mohitb117/demo_omdb_api/datamodels/SearchResult.kt @@ -21,7 +21,7 @@ data class SearchResult( @Parcelize data class SearchResultsBody( - val Search: List? = null, + val Search: List? = emptyList(), val totalResults: String, val Response: String ) : Parcelable diff --git a/app/src/main/java/com/mohitb117/demo_omdb_api/repositories/MovieRepository.kt b/app/src/main/java/com/mohitb117/demo_omdb_api/repositories/MovieRepository.kt index 367ff26..2cbe0b5 100644 --- a/app/src/main/java/com/mohitb117/demo_omdb_api/repositories/MovieRepository.kt +++ b/app/src/main/java/com/mohitb117/demo_omdb_api/repositories/MovieRepository.kt @@ -35,6 +35,16 @@ class MovieRepository suspend fun loadDetails(imdbId: String) = endpoint.loadDetails(API_KEY, imdbId) + suspend fun toggleFavourite(result: SearchResult): Boolean { + val currentState = isFavourite(result.imdbID) + when { + currentState -> delete(result.imdbID) + else -> insert(result) + } + + return !currentState + } + companion object { private const val API_KEY = "4f99d5d" } diff --git a/app/src/main/java/com/mohitb117/demo_omdb_api/ui/common/CommonUi.kt b/app/src/main/java/com/mohitb117/demo_omdb_api/ui/common/CommonUi.kt new file mode 100644 index 0000000..12bc805 --- /dev/null +++ b/app/src/main/java/com/mohitb117/demo_omdb_api/ui/common/CommonUi.kt @@ -0,0 +1,151 @@ +package com.mohitb117.demo_omdb_api.ui.common + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.mohitb117.demo_omdb_api.R +import com.mohitb117.demo_omdb_api.activities.ui.theme.DEMO_OMDB_APITheme +import com.mohitb117.demo_omdb_api.datamodels.SearchResult +import com.mohitb117.demo_omdb_api.datamodels.SearchResultsBody +import com.mohitb117.demo_omdb_api.ui.search.FavouritesComposableContent +import kotlinx.coroutines.launch +import kotlin.collections.orEmpty + +@Composable +fun MovieItemListComposable( + modifier: Modifier, + searchResults: SearchResultsBody, + isMovieFavourited: (SearchResult) -> Boolean = { false }, + onToggleMovieFavorited: suspend (SearchResult) -> Boolean = { false }, +) { + val searchItems = searchResults.Search.orEmpty() + val coroutineScope = rememberCoroutineScope() + + if (searchItems.isEmpty()) { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text(modifier = Modifier.fillMaxWidth(), text = "No results found") + } + } else { + LazyColumn( + modifier = Modifier.fillMaxWidth() + ) { + items( + items = searchItems, + key = { item -> item.imdbID } + ) { item -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Absolute.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + val borderWidth = 4.dp + val roundedCornerShape = RoundedCornerShape(borderWidth) + var isFavourited by rememberSaveable { mutableStateOf(false) } + + isFavourited = isMovieFavourited(item) + + AsyncImage( + model = item.poster, + placeholder = painterResource(id = R.mipmap.img), + modifier = Modifier + .size(100.dp) + .padding(5.dp) + .border( + BorderStroke(borderWidth, Color.Gray), + roundedCornerShape + ) + .padding(borderWidth / 2) + .clip(roundedCornerShape), + contentDescription = "Poster", + contentScale = ContentScale.Crop, + ) + + Text( + modifier = Modifier.fillMaxWidth(fraction = .3f), + text = item.title, + ) + + Switch( + modifier = Modifier.fillMaxWidth(fraction = .1f), + checked = isFavourited, + onCheckedChange = { + coroutineScope.launch { + val result = onToggleMovieFavorited(item) + isFavourited = result + } + } + ) + } + } + } + } +} + +@Preview(showBackground = true) +@Composable +fun MovieItemComposablePreview() { + DEMO_OMDB_APITheme { + FavouritesComposableContent( + uiState = setOf( + SearchResult( + imdbID = "tt0076759", + title = "Star Wars: Episode IV - A New Hope", + type = "movie", + poster = "https://m.media-amazon.com/images/M/MV5BOTA5NjhiOTAtZWM0ZC00MWNhLWE1OTktZDA4MjkwYmY0OTU3XkEyXkFqcGdeQXVyMTQxNzMzNDI@._V1_SX300.jpg" + ), + SearchResult( + imdbID = "tt0080684", + title = "Star Wars: Episode V - The Empire Strikes Back", + type = "movie", + poster = "https://m.media-amazon.com/images/M/MV5BYmU1ZTMwMWUtMWVlUC00MGRiLTgwOTYtOGVjOWFmZGUxMGRmXkEyXkFqcGdeQXVyNjU0OTQ0OTY@._V1_SX300.jpg" + ) + ), + ) + } +} + +@Preview(showBackground = true) +@Composable +fun MovieItemListComposablePreview() { + DEMO_OMDB_APITheme { + FavouritesComposableContent( + uiState = setOf( + SearchResult( + imdbID = "tt0076759", + title = "Star Wars: Episode IV - A New Hope", + type = "movie", + poster = "https://m.media-amazon.com/images/M/MV5BOTA5NjhiOTAtZWM0ZC00MWNhLWE1OTktZDA4MjkwYmY0OTU3XkEyXkFqcGdeQXVyMTQxNzMzNDI@._V1_SX300.jpg" + ), + ), + ) + } +} diff --git a/app/src/main/java/com/mohitb117/demo_omdb_api/ui/favourites/FavouritesComposable.kt b/app/src/main/java/com/mohitb117/demo_omdb_api/ui/favourites/FavouritesComposable.kt new file mode 100644 index 0000000..47dbd91 --- /dev/null +++ b/app/src/main/java/com/mohitb117/demo_omdb_api/ui/favourites/FavouritesComposable.kt @@ -0,0 +1,26 @@ +package com.mohitb117.demo_omdb_api.ui.search + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.mohitb117.demo_omdb_api.datamodels.SearchResult +import com.mohitb117.demo_omdb_api.datamodels.SearchResultsBody +import com.mohitb117.demo_omdb_api.ui.common.MovieItemListComposable + +@Composable +fun FavouritesComposableContent( + modifier: Modifier = Modifier, + uiState: Set, + isMovieFavourited: (SearchResult) -> Boolean = { false }, + onToggleMovieFavorited: suspend (SearchResult) -> Boolean = { false }, +) { + MovieItemListComposable( + modifier = modifier, + searchResults = SearchResultsBody( + Search = uiState.toList(), + totalResults = "", + Response = "", + ), + isMovieFavourited = isMovieFavourited, + onToggleMovieFavorited = onToggleMovieFavorited, + ) +} diff --git a/app/src/main/java/com/mohitb117/demo_omdb_api/ui/favourites/FavouritesFragment.kt b/app/src/main/java/com/mohitb117/demo_omdb_api/ui/favourites/FavouritesFragment.kt index f275f03..e1e8934 100644 --- a/app/src/main/java/com/mohitb117/demo_omdb_api/ui/favourites/FavouritesFragment.kt +++ b/app/src/main/java/com/mohitb117/demo_omdb_api/ui/favourites/FavouritesFragment.kt @@ -39,8 +39,9 @@ class FavouritesFragment : Fragment() { private val searchCallbacks = object : Callbacks { override fun gotoDetails(imdbId: String, position: Int) { - (activity as LaunchingActivity).gotoDetails(imdbId) - lastSelectedPosition = position +// TODO: +// (activity as LaunchingActivity).gotoDetails(imdbId) +// lastSelectedPosition = position } } diff --git a/app/src/main/java/com/mohitb117/demo_omdb_api/ui/favourites/FavouritesViewModel.kt b/app/src/main/java/com/mohitb117/demo_omdb_api/ui/favourites/FavouritesViewModel.kt index cdcad97..8711e9c 100644 --- a/app/src/main/java/com/mohitb117/demo_omdb_api/ui/favourites/FavouritesViewModel.kt +++ b/app/src/main/java/com/mohitb117/demo_omdb_api/ui/favourites/FavouritesViewModel.kt @@ -1,11 +1,16 @@ package com.mohitb117.demo_omdb_api.ui.favourites -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel -import com.mohitb117.demo_omdb_api.dao.FavouritesDao +import androidx.lifecycle.asFlow +import androidx.lifecycle.viewModelScope +import com.mohitb117.demo_omdb_api.datamodels.SearchResult import com.mohitb117.demo_omdb_api.repositories.MovieRepository import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch import javax.inject.Inject /** @@ -17,4 +22,22 @@ class FavouritesViewModel private val repository: MovieRepository ) : ViewModel() { fun getFavItems() = repository.getFavItems() + + private val _favouritedIds = MutableStateFlow>(emptySet()) + val favouritedIdsFlow = _favouritedIds.asStateFlow() + + init { + viewModelScope.launch { + repository.getFavItems() + .asFlow() + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(1000), + initialValue = emptyList() + ) + .collect { + _favouritedIds.value = it.toSet() + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/mohitb117/demo_omdb_api/ui/search/SearchComposable.kt b/app/src/main/java/com/mohitb117/demo_omdb_api/ui/search/SearchComposable.kt new file mode 100644 index 0000000..18df447 --- /dev/null +++ b/app/src/main/java/com/mohitb117/demo_omdb_api/ui/search/SearchComposable.kt @@ -0,0 +1,41 @@ +package com.mohitb117.demo_omdb_api.ui.search + +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.mohitb117.demo_omdb_api.datamodels.SearchResult +import com.mohitb117.demo_omdb_api.datamodels.SearchResultsBody +import com.mohitb117.demo_omdb_api.ui.common.MovieItemListComposable + +@Composable +fun SearchComposableContent( + modifier: Modifier = Modifier, + searchUiState: Result, + isMovieFavourited: (SearchResult) -> Boolean = { false }, + onToggleMovieFavorited: suspend (SearchResult) -> Boolean = { false }, +) { + when (searchUiState) { + is Result.Success -> { + MovieItemListComposable( + modifier = modifier, + searchResults = searchUiState.value, + isMovieFavourited = isMovieFavourited, + onToggleMovieFavorited = onToggleMovieFavorited, + ) + } + + is Result.Failure -> { + Text(text = "Error seen = ${searchUiState.error.localizedMessage.orEmpty()}") + } + + is Result.Loading -> { + CircularProgressIndicator( + modifier = Modifier.wrapContentSize(), + ) + } + + else -> Text("Something went wrong! $searchUiState") + } +} diff --git a/app/src/main/java/com/mohitb117/demo_omdb_api/ui/search/SearchFragment.kt b/app/src/main/java/com/mohitb117/demo_omdb_api/ui/search/SearchFragment.kt index 80e09dc..b53ea89 100644 --- a/app/src/main/java/com/mohitb117/demo_omdb_api/ui/search/SearchFragment.kt +++ b/app/src/main/java/com/mohitb117/demo_omdb_api/ui/search/SearchFragment.kt @@ -41,8 +41,9 @@ class SearchFragment : Fragment() { private val searchCallbacks = object : Callbacks { override fun gotoDetails(imdbId: String, position: Int) { - (activity as LaunchingActivity).gotoDetails(imdbId) - lastSelectedPosition = position +// TODO: +// (activity as LaunchingActivity).gotoDetails(imdbId) +// lastSelectedPosition = position } } @@ -86,9 +87,9 @@ class SearchFragment : Fragment() { searchTermEdittext.setOnEditorActionListener(nextListener) } - // Listen for Search Result Update. - searchViewModel.viewState - .observe(viewLifecycleOwner) { viewState -> handleSearchViewState(viewState) } +// // Listen for Search Result Update. +// searchViewModel.viewState +// .observe(viewLifecycleOwner) { viewState -> handleSearchViewState(viewState) } // Listen for State Update for items being marked as "fav" and selectively update the item. searchViewModel.getFavItems() diff --git a/app/src/main/java/com/mohitb117/demo_omdb_api/ui/search/SearchViewModel.kt b/app/src/main/java/com/mohitb117/demo_omdb_api/ui/search/SearchViewModel.kt index 49470e1..789273d 100644 --- a/app/src/main/java/com/mohitb117/demo_omdb_api/ui/search/SearchViewModel.kt +++ b/app/src/main/java/com/mohitb117/demo_omdb_api/ui/search/SearchViewModel.kt @@ -1,19 +1,34 @@ package com.mohitb117.demo_omdb_api.ui.search import android.util.Log -import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel +import androidx.lifecycle.asFlow import androidx.lifecycle.viewModelScope +import com.mohitb117.demo_omdb_api.datamodels.SearchResult import com.mohitb117.demo_omdb_api.datamodels.SearchResultsBody import com.mohitb117.demo_omdb_api.repositories.MovieRepository import com.slack.eithernet.ApiResult import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import javax.inject.Inject +sealed class Result(open val searchTerm: String) { + data class Success(val value: T, override val searchTerm: String) : Result(searchTerm) + data class Failure(val error: Throwable, override val searchTerm: String) : + Result(searchTerm) + + data class Loading(override val searchTerm: String) : Result(searchTerm) +} + data class SearchViewState( val searchTerm: String = "dogs", val searchResult: SearchResultsBody? = null, @@ -26,32 +41,80 @@ data class SearchViewState( @HiltViewModel class SearchViewModel @Inject constructor( - private val repository: MovieRepository + private val repository: MovieRepository, + private val savedStateHandle: SavedStateHandle ) : ViewModel() { - private val _viewState: MutableLiveData = MutableLiveData(null) - val viewState: LiveData = _viewState + private val _viewStateLiveData: MutableLiveData> = + MutableLiveData(Result.Loading("")) + + val searchResultsViewState = _viewStateLiveData.asFlow().stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(1000), + initialValue = Result.Loading("") + ) + + // Efficiently hold the IDs of favorited movies + + fun getFavItems() = repository.getFavItems() + + private val _favouritedIds = MutableStateFlow>(emptySet()) + val favouritedIds = _favouritedIds.asStateFlow() + + init { + viewModelScope.launch { + repository.getFavItems() + .asFlow() + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(1000), + initialValue = emptyList() + ) + .collect { + _favouritedIds.value = it.toSet() + } + } + } fun loadSearchResult(searchTerm: String) { + _viewStateLiveData.value = Result.Loading(searchTerm) + val exceptionHandler = CoroutineExceptionHandler { _, throwable -> - Log.e(SearchViewModel::class.java.simpleName, "Error Encountered: ${throwable.localizedMessage}") - _viewState.postValue(SearchViewState(searchTerm, null, throwable.localizedMessage)) + Log.e( + SearchViewModel::class.java.simpleName, + "Error Encountered: ${throwable.localizedMessage}" + ) + val error = Throwable(throwable.localizedMessage) + _viewStateLiveData.value = Result.Failure(error = error, searchTerm = searchTerm) } - viewModelScope.launch(Dispatchers.IO + exceptionHandler) { - when(val result = repository.loadResults(searchTerm)) { - is ApiResult.Success -> _viewState.postValue(SearchViewState(searchTerm, result.value)) + viewModelScope.launch(exceptionHandler) { + val result = when ( + val result = withContext(Dispatchers.IO) { repository.loadResults(searchTerm) } + ) { + is ApiResult.Success -> Result.Success( + searchTerm = searchTerm, + value = result.value + ) - is ApiResult.Failure -> when(result) { - is ApiResult.Failure.ApiFailure -> _viewState.postValue(SearchViewState(searchTerm, null, result.error.toString())) - is ApiResult.Failure.HttpFailure -> _viewState.postValue(SearchViewState(searchTerm, null, result.error.toString())) - is ApiResult.Failure.NetworkFailure -> _viewState.postValue(SearchViewState(searchTerm, null, result.error.toString())) - is ApiResult.Failure.UnknownFailure -> _viewState.postValue(SearchViewState(searchTerm, null, result.error.toString())) - else -> _viewState.postValue(SearchViewState(searchTerm, null, "Not sure what is going on!!! :sob: ")) + is ApiResult.Failure -> { + val error = when (result) { + is ApiResult.Failure.ApiFailure -> result.error.toString() + is ApiResult.Failure.HttpFailure -> result.error.toString() + is ApiResult.Failure.NetworkFailure -> result.error.toString() + is ApiResult.Failure.UnknownFailure -> result.error.toString() + else -> "Not sure what is going on!!! :sob: " + } + + Result.Failure(error = Throwable(error), searchTerm = searchTerm) } } + + _viewStateLiveData.value = result } } - fun getFavItems() = repository.getFavItems() -} \ No newline at end of file + suspend fun toggleFavourite(result: SearchResult): Boolean { + return repository.toggleFavourite(result) + } +} diff --git a/app/src/main/res/mipmap-hdpi/img.png b/app/src/main/res/mipmap-hdpi/img.png new file mode 100644 index 0000000..c8f3ef7 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/img.png differ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 69d78d6..743c0d8 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -7,4 +7,5 @@ Favorites Shopping Profile + MainActivity \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 98bed16..dbc9506 100644 --- a/gradle.properties +++ b/gradle.properties @@ -16,6 +16,6 @@ org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 # https://developer.android.com/topic/libraries/support-library/androidx-rn android.useAndroidX=true # Automatically convert third-party libraries to use AndroidX -android.enableJetifier=true +android.enableJetifier=false # Kotlin code style for this project: "official" or "obsolete": kotlin.code.style=official \ No newline at end of file