Update App Architecture

This commit is contained in:
2021-09-07 05:36:04 +02:00
parent 1367955396
commit abb21c994f
53 changed files with 867 additions and 245 deletions
+4 -4
View File
@@ -1,6 +1,5 @@
import de.fayard.refreshVersions.core.versionFor
plugins {
id("com.android.application")
kotlin("android")
@@ -68,6 +67,7 @@ android {
excludes += "META-INF/LICENSE.md"
excludes += "META-INF/LICENSE-notice.md"
excludes += "META-INF/LGPL2.1"
excludes += "META-INF/com/android/build/gradle/aar-metadata.properties"
excludes += "win32-x86/attach_hotspot_windows.dll"
excludes += "win32-x86-64/attach_hotspot_windows.dll"
}
@@ -119,9 +119,9 @@ dependencies {
implementation(Libs.lottie_compose)
implementation(Libs.orbit_mvi_core)
implementation(Libs.orbit_mvi_viewmodel)
testImplementation(Libs.orbit_mvi_test)
implementation(Libs.mavericks_compose)
testImplementation(Libs.mavericks_testing)
testImplementation(Libs.mavericks_mocking)
kapt(Libs.room_compiler)
testImplementation(Libs.room_testing)
+3
View File
@@ -47,6 +47,9 @@
<meta-data
android:name="com.ericampire.android.androidstudycase.app.initializer.TimberInitializer"
android:value="androidx.startup" />
<meta-data
android:name="com.ericampire.android.androidstudycase.app.initializer.MavericksInitializer"
android:value="androidx.startup" />
</provider>
</application>
@@ -1,22 +1,20 @@
package com.ericampire.android.androidstudycase.app
import androidx.compose.material.ExperimentalMaterialApi
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.navigation.NavController
import androidx.navigation.NavGraphBuilder
import androidx.navigation.compose.composable
import com.ericampire.android.androidstudycase.presentation.screen.explore.ui.ExploreScreen
import com.ericampire.android.androidstudycase.presentation.screen.home.ui.HomeScreen
import com.ericampire.android.androidstudycase.presentation.screen.login.ui.LoginScreen
import com.ericampire.android.androidstudycase.presentation.screen.preview.ui.PreviewScreen
import com.ericampire.android.androidstudycase.util.Destination
import com.google.accompanist.pager.ExperimentalPagerApi
@ExperimentalMaterialApi
fun NavGraphBuilder.addHomeScreen(navController: NavController) {
composable(Destination.Home.route) {
HomeScreen(
navController = navController,
viewModel = hiltViewModel()
)
HomeScreen(navController = navController)
}
}
@@ -24,18 +22,20 @@ fun NavGraphBuilder.addHomeScreen(navController: NavController) {
@ExperimentalPagerApi
fun NavGraphBuilder.addExploreScreen(navController: NavController) {
composable(Destination.Explore.route) {
ExploreScreen(
navController = navController,
viewModel = hiltViewModel()
)
ExploreScreen(navController = navController)
}
}
@ExperimentalMaterialApi
@ExperimentalPagerApi
fun NavGraphBuilder.addLoginScreen(navController: NavController) {
composable(Destination.Login.route) {
LoginScreen(navController = navController)
}
}
fun NavGraphBuilder.addPreviewScreen(navController: NavController) {
composable(Destination.Preview.route) {
PreviewScreen(
navController = navController,
viewModel = hiltViewModel()
)
PreviewScreen(navController = navController)
}
}
@@ -9,13 +9,18 @@ import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@Module
@InstallIn(SingletonComponent::class)
object CoroutineModule {
@Provides
fun provideCoroutineScope(): CoroutineScope = CoroutineScope(SupervisorJob())
@Provides
@IoDispatcher
fun providesIoDispatcher(): CoroutineDispatcher = Dispatchers.IO
@@ -1,47 +1,30 @@
package com.ericampire.android.androidstudycase.app.di
import com.ericampire.android.androidstudycase.domain.usecase.*
import com.ericampire.android.androidstudycase.app.hilt.AssistedViewModelFactory
import com.ericampire.android.androidstudycase.app.hilt.MavericksViewModelComponent
import com.ericampire.android.androidstudycase.app.hilt.ViewModelKey
import com.ericampire.android.androidstudycase.presentation.screen.explore.business.ExploreViewModel
import com.ericampire.android.androidstudycase.presentation.screen.home.business.HomeViewModel
import com.ericampire.android.androidstudycase.presentation.screen.preview.business.PreviewViewModel
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.components.ViewModelComponent
import dagger.multibindings.IntoMap
@Module
@InstallIn(ViewModelComponent::class)
object ViewModelModule {
@InstallIn(MavericksViewModelComponent::class)
interface ViewModelModule {
@Provides
@Binds
@IntoMap
@ViewModelKey(HomeViewModel::class)
fun provideHomeViewModel(
findFeaturedAnimatorUseCase: FindFeaturedAnimatorUseCase,
findFeaturedBlogUseCase: FindFeaturedBlogUseCase
): HomeViewModel {
return HomeViewModel(
findFeaturedAnimatorUseCase = findFeaturedAnimatorUseCase,
findFeaturedBlogUseCase = findFeaturedBlogUseCase
)
}
factory: HomeViewModel.Factory
): AssistedViewModelFactory<*, *>
@Provides
@Binds
@IntoMap
@ViewModelKey(ExploreViewModel::class)
fun provideExploreViewModel(
findFeaturedAnimatorUseCase: FindFeaturedAnimatorUseCase,
findFeaturedBlogUseCase: FindFeaturedBlogUseCase
): PreviewViewModel {
return PreviewViewModel()
}
@Provides
fun providePreview(
findPopularLottieFileUseCase: FindPopularLottieFileUseCase,
findRecentLottieFileUseCase: FindRecentLottieFileUseCase,
findFeaturedLottieFileUseCase: FindFeaturedLottieFileUseCase
): ExploreViewModel {
return ExploreViewModel(
findPopularLottieFileUseCase = findPopularLottieFileUseCase,
findRecentLottieFileUseCase = findRecentLottieFileUseCase,
findFeaturedLottieFileUseCase = findFeaturedLottieFileUseCase
)
}
factory: ExploreViewModel.Factory
): AssistedViewModelFactory<*, *>
}
@@ -0,0 +1,9 @@
package com.ericampire.android.androidstudycase.app.hilt
import com.airbnb.mvrx.MavericksState
import com.airbnb.mvrx.MavericksViewModel
interface AssistedViewModelFactory<VM : MavericksViewModel<S>, S : MavericksState> {
fun create(state: S): VM
}
@@ -0,0 +1,59 @@
package com.ericampire.android.androidstudycase.app.hilt
import com.airbnb.mvrx.MavericksState
import com.airbnb.mvrx.MavericksViewModel
import com.airbnb.mvrx.MavericksViewModelFactory
import com.airbnb.mvrx.ViewModelContext
import dagger.hilt.DefineComponent
import dagger.hilt.EntryPoint
import dagger.hilt.EntryPoints
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
inline fun <reified VM : MavericksViewModel<S>, S : MavericksState> hiltMavericksViewModelFactory() =
HiltMavericksViewModelFactory<VM, S>(VM::class.java)
class HiltMavericksViewModelFactory<VM : MavericksViewModel<S>, S : MavericksState>(
private val viewModelClass: Class<out MavericksViewModel<S>>
) : MavericksViewModelFactory<VM, S> {
override fun create(viewModelContext: ViewModelContext, state: S): VM {
// We want to create the ViewModelComponent. In order to do that, we need to get its parent: ActivityComponent.
val componentBuilder =
EntryPoints.get(viewModelContext.app(), CreateMavericksViewModelComponent::class.java)
.mavericksViewModelComponentBuilder()
val viewModelComponent = componentBuilder.build()
val viewModelFactoryMap = EntryPoints.get(
viewModelComponent,
HiltMavericksEntryPoint::class.java
).viewModelFactories
val viewModelFactory = viewModelFactoryMap[viewModelClass]
@Suppress("UNCHECKED_CAST")
val castedViewModelFactory = viewModelFactory as? AssistedViewModelFactory<VM, S>
return castedViewModelFactory?.create(state) as VM
}
}
@MavericksViewModelScoped
@DefineComponent(parent = SingletonComponent::class)
interface MavericksViewModelComponent
@DefineComponent.Builder
interface MavericksViewModelComponentBuilder {
fun build(): MavericksViewModelComponent
}
@EntryPoint
@InstallIn(SingletonComponent::class)
interface CreateMavericksViewModelComponent {
fun mavericksViewModelComponentBuilder(): MavericksViewModelComponentBuilder
}
@EntryPoint
@InstallIn(MavericksViewModelComponent::class)
interface HiltMavericksEntryPoint {
val viewModelFactories: Map<Class<out MavericksViewModel<*>>, AssistedViewModelFactory<*, *>>
}
@@ -0,0 +1,6 @@
package com.ericampire.android.androidstudycase.app.hilt
import javax.inject.Scope
@Scope
annotation class MavericksViewModelScoped
@@ -0,0 +1,13 @@
package com.ericampire.android.androidstudycase.app.hilt
import com.airbnb.mvrx.MavericksViewModel
import dagger.MapKey
import kotlin.reflect.KClass
/**
* A [MapKey] for populating a map of ViewModels and their factories.
*/
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.FUNCTION)
@MapKey
annotation class ViewModelKey(val value: KClass<out MavericksViewModel<*>>)
@@ -0,0 +1,13 @@
package com.ericampire.android.androidstudycase.app.hilt
import java.util.concurrent.atomic.AtomicInteger
import javax.inject.Inject
@MavericksViewModelScoped
class ViewModelScopedClass @Inject constructor() {
val id = instanceId.incrementAndGet()
companion object {
private val instanceId = AtomicInteger(0)
}
}
@@ -0,0 +1,15 @@
package com.ericampire.android.androidstudycase.app.initializer
import android.content.Context
import androidx.startup.Initializer
import com.airbnb.mvrx.Mavericks
class MavericksInitializer : Initializer<Unit> {
override fun create(context: Context) {
Mavericks.initialize(context)
}
override fun dependencies(): MutableList<Class<out Initializer<*>>> {
return mutableListOf()
}
}
@@ -13,7 +13,7 @@ import com.ericampire.android.androidstudycase.domain.entity.User
@Database(
entities = [Blog::class, Animator::class, Lottiefile::class, User::class],
version = 2,
version = 1,
exportSchema = false
)
abstract class AppDatabase : RoomDatabase() {
@@ -0,0 +1,199 @@
package com.ericampire.android.androidstudycase.presentation.custom
import androidx.compose.animation.core.*
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Surface
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.ericampire.android.androidstudycase.presentation.theme.AndroidStudyCaseTheme
import com.ericampire.android.androidstudycase.presentation.theme.AppColor
@Composable
fun LoadingAnimation(
modifier: Modifier = Modifier,
waveColor: Color = AppColor.WhiteTransparent,
arcColor: Color = Color.White
) {
var currentRotation by remember { mutableStateOf(0f) }
val rotation = remember { Animatable(currentRotation) }
LaunchedEffect(true) {
rotation.animateTo(
targetValue = currentRotation + 360f,
animationSpec = infiniteRepeatable(
animation = tween(2000, easing = LinearEasing),
repeatMode = RepeatMode.Restart
),
block = {
currentRotation = value
}
)
}
Surface(
modifier = modifier.size(200.dp),
color = Color.Transparent,
content = {
Box(
contentAlignment = Alignment.Center,
content = {
Canvas(modifier = Modifier.size(100.dp)) {
drawCircle(
style = Stroke(width = 10f),
color = waveColor
)
drawArc(
color = arcColor,
startAngle = rotation.value,
sweepAngle = 30f,
useCenter = false,
style = Stroke(width = 10f),
)
}
Surface(
modifier = Modifier.size(90.dp),
shape = CircleShape,
color = Color.Transparent,
content = {
WaveView(
waveColor = waveColor,
timeSpec = 5000,
init = true,
initValue = 1f,
targetValue = 0f
)
WaveView(
waveColor = waveColor,
timeSpec = 5000,
init = true,
initValue = 0f,
targetValue = 1f,
waveWidth = 200
)
WaveView(
waveColor = waveColor,
timeSpec = 2000,
init = true,
initValue = 0f,
targetValue = 1f,
waveWidth = 350,
dxTimeSpec = 2000
)
}
)
}
)
}
)
}
@Composable
fun WaveView(
modifier: Modifier = Modifier,
timeSpec: Long,
initValue: Float,
targetValue: Float,
init: Boolean,
waveWidth: Int = 250,
dxTimeSpec: Int = 4000,
waveColor: Color,
) {
val deltaXAnim = rememberInfiniteTransition()
val dx by deltaXAnim.animateFloat(
initialValue = initValue,
targetValue = targetValue,
animationSpec = infiniteRepeatable(
animation = tween(dxTimeSpec, easing = LinearEasing)
)
)
val dy by deltaXAnim.animateFloat(
initialValue = 100f,
targetValue = 0f,
animationSpec = infiniteRepeatable(
animation = tween(4000, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
)
)
val screenWidthPx = with(LocalDensity.current) {
(LocalConfiguration.current.screenHeightDp * density) - 80.dp.toPx()
}
val animTranslate by animateFloatAsState(
targetValue = if (init) 0f else screenWidthPx,
animationSpec = TweenSpec(if (init) 0 else timeSpec.toInt(), easing = LinearEasing)
)
val waveHeight by animateFloatAsState(
targetValue = if (init) 80f else 0f,
animationSpec = TweenSpec(if (init) 0 else timeSpec.toInt(), easing = LinearEasing)
)
val path = Path()
Canvas(
modifier = modifier.fillMaxSize(),
onDraw = {
translate(top = animTranslate) {
drawPath(path = path, color = waveColor)
path.reset()
val halfWaveWidth = waveWidth / 2
path.moveTo(-waveWidth + (waveWidth * dx), dy.dp.toPx())
for (i in -waveWidth..(size.width.toInt() + waveWidth) step waveWidth) {
path.relativeQuadraticBezierTo(
halfWaveWidth.toFloat() / 2,
-waveHeight,
halfWaveWidth.toFloat(),
0f
)
path.relativeQuadraticBezierTo(
halfWaveWidth.toFloat() / 2,
waveHeight,
halfWaveWidth.toFloat(),
0f
)
}
path.lineTo(size.width, size.height)
path.lineTo(0f, size.height)
path.close()
}
}
)
}
@Preview
@Composable
fun LoadingAnimationPreview() {
AndroidStudyCaseTheme() {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.fillMaxSize(),
content = {
LoadingAnimation(
waveColor = MaterialTheme.colors.primary.copy(alpha = 0.5f),
arcColor = MaterialTheme.colors.primaryVariant
)
}
)
}
}
@@ -1,5 +0,0 @@
package com.ericampire.android.androidstudycase.presentation.screen.explore.business
sealed interface ExploreEffect {
data class ShowErrorMessage(val message: String) : ExploreEffect
}
@@ -1,33 +1,29 @@
package com.ericampire.android.androidstudycase.presentation.screen.explore.business
import androidx.lifecycle.viewModelScope
import com.airbnb.mvrx.MavericksViewModelFactory
import com.ericampire.android.androidstudycase.app.hilt.AssistedViewModelFactory
import com.ericampire.android.androidstudycase.app.hilt.hiltMavericksViewModelFactory
import com.ericampire.android.androidstudycase.domain.entity.Lottiefile
import com.ericampire.android.androidstudycase.domain.usecase.FindFeaturedLottieFileUseCase
import com.ericampire.android.androidstudycase.domain.usecase.FindPopularLottieFileUseCase
import com.ericampire.android.androidstudycase.domain.usecase.FindRecentLottieFileUseCase
import com.ericampire.android.androidstudycase.util.Result
import com.ericampire.android.androidstudycase.util.data
import com.ericampire.android.androidstudycase.util.mvi.BaseViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import org.orbitmvi.orbit.Container
import org.orbitmvi.orbit.syntax.simple.intent
import org.orbitmvi.orbit.syntax.simple.postSideEffect
import org.orbitmvi.orbit.syntax.simple.reduce
import org.orbitmvi.orbit.viewmodel.container
import javax.inject.Inject
@HiltViewModel
class ExploreViewModel @Inject constructor(
class ExploreViewModel @AssistedInject constructor(
@Assisted initialState: ExploreViewState,
private val findPopularLottieFileUseCase: FindPopularLottieFileUseCase,
private val findRecentLottieFileUseCase: FindRecentLottieFileUseCase,
private val findFeaturedLottieFileUseCase: FindFeaturedLottieFileUseCase
) : BaseViewModel<ExploreViewState, ExploreEffect, ExploreAction>() {
override val container: Container<ExploreViewState, ExploreEffect>
get() = container(initialState = ExploreViewState())
) : BaseViewModel<ExploreViewState, ExploreAction>(initialState) {
init {
viewModelScope.launch {
@@ -41,21 +37,21 @@ class ExploreViewModel @Inject constructor(
}
}
private fun Flow<Result<List<Lottiefile>>>.fetchData() = intent(registerIdling = false) {
collect { result ->
when (result) {
is Result.Error -> {
val errorMessage = result.exception.localizedMessage ?: "Unknown Error"
reduce { state.copy(isLoading = false) }
postSideEffect(ExploreEffect.ShowErrorMessage(errorMessage))
}
Result.Loading -> reduce {
state.copy(isLoading = true)
}
is Result.Success -> reduce {
state.copy(files = result.data, isLoading = false)
}
private fun Flow<Result<List<Lottiefile>>>.fetchData() {
viewModelScope.launch {
map {
it.data ?: emptyList()
}.execute {
copy(files = it)
}
}
}
@AssistedFactory
interface Factory : AssistedViewModelFactory<ExploreViewModel, ExploreViewState> {
override fun create(state: ExploreViewState): ExploreViewModel
}
companion object : MavericksViewModelFactory<ExploreViewModel, ExploreViewState>
by hiltMavericksViewModelFactory()
}
@@ -1,8 +1,10 @@
package com.ericampire.android.androidstudycase.presentation.screen.explore.business
import com.airbnb.mvrx.Async
import com.airbnb.mvrx.MavericksState
import com.airbnb.mvrx.Uninitialized
import com.ericampire.android.androidstudycase.domain.entity.Lottiefile
data class ExploreViewState(
val files: List<Lottiefile> = emptyList(),
val isLoading: Boolean = false
)
val files: Async<List<Lottiefile>> = Uninitialized,
) : MavericksState
@@ -1,6 +1,6 @@
package com.ericampire.android.androidstudycase.presentation.screen.explore.ui
import android.widget.Toast
import androidx.compose.animation.Crossfade
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
@@ -18,45 +18,43 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.airbnb.mvrx.Fail
import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.Success
import com.airbnb.mvrx.Uninitialized
import com.airbnb.mvrx.compose.collectAsState
import com.airbnb.mvrx.compose.mavericksViewModel
import com.ericampire.android.androidstudycase.R
import com.ericampire.android.androidstudycase.domain.entity.Lottiefile
import com.ericampire.android.androidstudycase.presentation.custom.LoadingView
import com.ericampire.android.androidstudycase.presentation.custom.LoadingAnimation
import com.ericampire.android.androidstudycase.presentation.custom.TopActionBar
import com.ericampire.android.androidstudycase.presentation.screen.explore.business.ExploreAction
import com.ericampire.android.androidstudycase.presentation.screen.explore.business.ExploreEffect
import com.ericampire.android.androidstudycase.presentation.screen.explore.business.ExploreViewModel
import com.ericampire.android.androidstudycase.presentation.screen.explore.business.ExploreViewState
import com.ericampire.android.androidstudycase.presentation.theme.AppColor
import com.google.accompanist.pager.ExperimentalPagerApi
import com.google.accompanist.pager.HorizontalPager
import com.google.accompanist.pager.pagerTabIndicatorOffset
import com.google.accompanist.pager.rememberPagerState
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import timber.log.Timber
@ExperimentalMaterialApi
@ExperimentalPagerApi
@Composable
fun ExploreScreen(
navController: NavController,
viewModel: ExploreViewModel
viewModel: ExploreViewModel = mavericksViewModel()
) {
val coroutineScope = rememberCoroutineScope()
val state by viewModel.container.stateFlow.collectAsState()
val state by viewModel.collectAsState(ExploreViewState::files)
val context = LocalContext.current
val tabItems = stringArrayResource(id = R.array.explore_item)
val pagerState = rememberPagerState(pageCount = tabItems.size)
LaunchedEffect(viewModel) {
viewModel.container.sideEffectFlow.collect {
when (it) {
is ExploreEffect.ShowErrorMessage -> {
Toast.makeText(context, it.message, Toast.LENGTH_LONG).show()
}
}
}
}
LaunchedEffect(viewModel) {
viewModel.submitAction(ExploreAction.FindRecentFile)
}
@@ -122,38 +120,70 @@ fun ExploreScreen(
)
},
content = { contentPadding ->
Box(
modifier = Modifier
.padding(contentPadding)
.fillMaxSize(),
contentAlignment = Alignment.Center,
content = {
if (state.isLoading) {
LoadingView()
Crossfade(modifier = Modifier.padding(contentPadding), targetState = state) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
content = {
when (it) {
Uninitialized -> {
LoadingAnimation(
waveColor = MaterialTheme.colors.primary.copy(alpha = 0.5f),
arcColor = MaterialTheme.colors.primaryVariant
)
}
is Loading -> {
LoadingAnimation(
waveColor = MaterialTheme.colors.primary.copy(alpha = 0.5f),
arcColor = MaterialTheme.colors.primaryVariant
)
}
is Success -> {
val animations = it.invoke()
HorizontalPager(state = pagerState) {
ExploreContent(files = animations)
}
}
is Fail -> {
Timber.e(it.error.localizedMessage)
}
}
}
if (state.files.isNotEmpty()) {
ExploreContent(files = state.files)
}
}
)
)
}
}
)
}
@ExperimentalMaterialApi
@Composable
fun ExploreContent(
private fun ExploreContent(
modifier: Modifier = Modifier,
files: List<Lottiefile>
) {
LazyColumn(
modifier = modifier.fillMaxSize(),
content = {
item {
Divider(
modifier = Modifier
.background(MaterialTheme.colors.surface)
.height(18.dp)
)
}
items(items = files, key = { it.toString() }) { lottieFile ->
LottieFileItemView(
lottiefile = lottieFile,
onClick = {}
)
Divider(
modifier = Modifier
.background(MaterialTheme.colors.surface)
.height(18.dp)
)
}
}
)
@@ -6,14 +6,16 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.CornerSize
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.*
import androidx.compose.material.icons.rounded.ChatBubble
import androidx.compose.material.icons.rounded.CreateNewFolder
import androidx.compose.material.icons.rounded.Favorite
import androidx.compose.material.icons.rounded.Share
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
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.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
@@ -72,12 +74,10 @@ fun LottieFileItemView(
Text(
text = lottiefile.name,
style = MaterialTheme.typography.h6,
textAlign = TextAlign.Center,
)
Text(
text = lottiefile.createdBy?.name ?: "",
style = MaterialTheme.typography.caption,
textAlign = TextAlign.Center,
)
}
)
@@ -1,3 +1,5 @@
package com.ericampire.android.androidstudycase.presentation.screen.home.business
sealed interface HomeAction
sealed interface HomeAction {
object FetchData : HomeAction
}
@@ -1,3 +0,0 @@
package com.ericampire.android.androidstudycase.presentation.screen.home.business
sealed interface HomeEffect
@@ -1,30 +1,68 @@
package com.ericampire.android.androidstudycase.presentation.screen.home.business
import androidx.lifecycle.viewModelScope
import com.airbnb.mvrx.MavericksViewModelFactory
import com.ericampire.android.androidstudycase.app.hilt.AssistedViewModelFactory
import com.ericampire.android.androidstudycase.app.hilt.hiltMavericksViewModelFactory
import com.ericampire.android.androidstudycase.domain.usecase.FindFeaturedAnimatorUseCase
import com.ericampire.android.androidstudycase.domain.usecase.FindFeaturedBlogUseCase
import com.ericampire.android.androidstudycase.domain.usecase.FindFeaturedLottieFileUseCase
import com.ericampire.android.androidstudycase.domain.usecase.FindUsersUseCase
import com.ericampire.android.androidstudycase.util.data
import com.ericampire.android.androidstudycase.util.mvi.BaseViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch
import org.orbitmvi.orbit.Container
import org.orbitmvi.orbit.viewmodel.container
import javax.inject.Inject
@HiltViewModel
class HomeViewModel @Inject constructor(
class HomeViewModel @AssistedInject constructor(
@Assisted initialState: HomeViewState,
private val findFeaturedBlogUseCase: FindFeaturedBlogUseCase,
private val findFeaturedAnimatorUseCase: FindFeaturedAnimatorUseCase
) : BaseViewModel<HomeViewState, HomeEffect, HomeAction>() {
private val findFeaturedAnimatorUseCase: FindFeaturedAnimatorUseCase,
private val findUsersUseCase: FindUsersUseCase,
private val findFeaturedLottieFileUseCase: FindFeaturedLottieFileUseCase
) : BaseViewModel<HomeViewState, HomeAction>(initialState) {
override val container: Container<HomeViewState, HomeEffect>
get() = container(initialState = HomeViewState())
init {
viewModelScope.launch {
pendingAction.collectLatest { action ->
when (action) {
HomeAction.FetchData -> fetchData()
}
}
}
}
private fun fetchData() {
val userFlow = findUsersUseCase(Unit)
val storiesFlow = findFeaturedBlogUseCase(Unit)
val animatorsFlow = findFeaturedAnimatorUseCase(Unit)
val animationsFlow = findFeaturedLottieFileUseCase(Unit)
combine(
userFlow,
storiesFlow,
animationsFlow,
animatorsFlow
) { user, stories, anim, animators ->
HomeContentData(
user = user.data?.firstOrNull(),
blog = stories.data ?: emptyList(),
featuredAnimators = animators.data ?: emptyList(),
featuredLottieFile = anim.data ?: emptyList(),
)
}.execute {
copy(contentData = it)
}
}
@AssistedFactory
interface Factory : AssistedViewModelFactory<HomeViewModel, HomeViewState> {
override fun create(state: HomeViewState): HomeViewModel
}
companion object : MavericksViewModelFactory<HomeViewModel, HomeViewState>
by hiltMavericksViewModelFactory()
}
@@ -1,7 +1,20 @@
package com.ericampire.android.androidstudycase.presentation.screen.home.business
import com.airbnb.mvrx.Async
import com.airbnb.mvrx.MavericksState
import com.airbnb.mvrx.Uninitialized
import com.ericampire.android.androidstudycase.domain.entity.Animator
import com.ericampire.android.androidstudycase.domain.entity.Blog
import com.ericampire.android.androidstudycase.domain.entity.Lottiefile
import com.ericampire.android.androidstudycase.domain.entity.User
data class HomeViewState(
val blog: List<Blog> = emptyList()
)
val contentData: Async<HomeContentData> = Uninitialized
) : MavericksState
data class HomeContentData(
val blog: List<Blog> = emptyList(),
val featuredAnimators: List<Animator> = emptyList(),
val featuredLottieFile: List<Lottiefile> = emptyList(),
val user: User? = null,
) : MavericksState
@@ -45,7 +45,7 @@ fun FeaturedLottieFileView(
Box(
modifier = modifier
.background(Color.White)
.height(200.dp)
.height(170.dp)
.width(170.dp),
content = {
LottieAnimation(
@@ -71,7 +71,9 @@ fun FeaturedLottieFileView(
Text(
text = lottiefile.createdBy?.name ?: "",
maxLines = 1,
style = MaterialTheme.typography.caption,
style = MaterialTheme.typography.caption.copy(
color = Color.Gray
),
)
}
)
@@ -1,13 +1,206 @@
package com.ericampire.android.androidstudycase.presentation.screen.home.ui
import androidx.compose.animation.Crossfade
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.MaterialTheme
import androidx.compose.material.Scaffold
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import com.airbnb.mvrx.Fail
import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.Success
import com.airbnb.mvrx.Uninitialized
import com.airbnb.mvrx.compose.collectAsState
import com.airbnb.mvrx.compose.mavericksViewModel
import com.ericampire.android.androidstudycase.R
import com.ericampire.android.androidstudycase.presentation.custom.CustomImageView
import com.ericampire.android.androidstudycase.presentation.custom.LoadingAnimation
import com.ericampire.android.androidstudycase.presentation.custom.TopActionBar
import com.ericampire.android.androidstudycase.presentation.screen.home.business.HomeAction
import com.ericampire.android.androidstudycase.presentation.screen.home.business.HomeContentData
import com.ericampire.android.androidstudycase.presentation.screen.home.business.HomeViewModel
import com.ericampire.android.androidstudycase.presentation.screen.home.business.HomeViewState
import com.ericampire.android.androidstudycase.presentation.theme.AppColor
import com.ericampire.android.androidstudycase.util.Destination
import timber.log.Timber
@ExperimentalMaterialApi
@Composable
fun HomeScreen(
navController: NavController,
viewModel: HomeViewModel
viewModel: HomeViewModel = mavericksViewModel()
) {
}
val state by viewModel.collectAsState(HomeViewState::contentData)
LaunchedEffect(viewModel) {
viewModel.submitAction(HomeAction.FetchData)
}
Scaffold(
topBar = {
Column(
modifier = Modifier
.fillMaxWidth()
.background(color = AppColor.Black001),
content = {
TopActionBar()
}
)
},
content = { contentPadding ->
Crossfade(modifier = Modifier.padding(contentPadding), targetState = state) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
content = {
when (it) {
Uninitialized -> {
LoadingAnimation(
waveColor = MaterialTheme.colors.primary.copy(alpha = 0.5f),
arcColor = MaterialTheme.colors.primaryVariant
)
}
is Loading -> {
LoadingAnimation(
waveColor = MaterialTheme.colors.primary.copy(alpha = 0.5f),
arcColor = MaterialTheme.colors.primaryVariant
)
}
is Success -> {
HomeContent(
state = it.invoke(),
onLoginClick = {
navController.navigate(Destination.Login.route)
}
)
}
is Fail -> {
Timber.e(it.error.localizedMessage)
}
}
}
)
}
}
)
}
@ExperimentalMaterialApi
@Composable
fun HomeContent(
modifier: Modifier = Modifier,
state: HomeContentData,
onLoginClick: () -> Unit
) {
LazyColumn(
state = rememberLazyListState(),
modifier = modifier
.fillMaxSize()
.animateContentSize(),
verticalArrangement = Arrangement.spacedBy(16.dp),
content = {
item {
if (state.user == null) {
UnLoggedUserHeaderView(onLoginClick = onLoginClick)
} else {
LoggedUserHeaderView(user = state.user)
}
}
item {
Text(
modifier = Modifier.padding(horizontal = 16.dp),
maxLines = 1,
text = stringResource(id = R.string.txt_featured_animation),
style = MaterialTheme.typography.h4.copy(
color = MaterialTheme.colors.onSurface
),
)
}
item {
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
content = {
items(items = state.featuredLottieFile, key = { it.toString() }) { animation ->
FeaturedLottieFileView(
lottiefile = animation,
onClick = {
// Todo:
}
)
}
}
)
}
item {
Text(
modifier = Modifier.padding(horizontal = 16.dp),
maxLines = 1,
text = stringResource(id = R.string.txt_featured_animator),
style = MaterialTheme.typography.h4.copy(
color = MaterialTheme.colors.onSurface
),
)
}
item {
LazyRow(
contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
content = {
items(items = state.featuredAnimators) { animator ->
CustomImageView(
modifier = Modifier
.size(70.dp)
.clip(CircleShape),
data = animator.avatarUrl,
)
}
}
)
}
item {
Text(
modifier = Modifier.padding(horizontal = 16.dp),
maxLines = 1,
text = stringResource(id = R.string.txt_latest_story),
style = MaterialTheme.typography.h4.copy(
color = MaterialTheme.colors.onSurface
),
)
}
items(items = state.blog) { blog ->
BlogItemView(
modifier = Modifier.padding(horizontal = 16.dp),
blog = blog,
onClick = { }
)
}
item {
BrowseAllItemView()
}
}
)
}
@@ -0,0 +1,10 @@
package com.ericampire.android.androidstudycase.presentation.screen.login.business
import androidx.lifecycle.ViewModel
import com.ericampire.android.androidstudycase.domain.usecase.SaveUserUseCase
import javax.inject.Inject
class LoginViewModel @Inject constructor(
private val saveUserUseCase: SaveUserUseCase
) : ViewModel() {
}
@@ -0,0 +1,11 @@
package com.ericampire.android.androidstudycase.presentation.screen.login.ui
import androidx.compose.runtime.Composable
import androidx.navigation.NavController
@Composable
fun LoginScreen(
navController: NavController,
) {
}
@@ -19,6 +19,7 @@ import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.ericampire.android.androidstudycase.app.addExploreScreen
import com.ericampire.android.androidstudycase.app.addHomeScreen
import com.ericampire.android.androidstudycase.app.addLoginScreen
import com.ericampire.android.androidstudycase.app.addPreviewScreen
import com.ericampire.android.androidstudycase.util.Destination
import com.google.accompanist.insets.navigationBarsPadding
@@ -84,6 +85,7 @@ fun MainScreen() {
addHomeScreen(navController = navController)
addPreviewScreen(navController = navController)
addExploreScreen(navController = navController)
addLoginScreen(navController = navController)
}
)
},
@@ -1,3 +0,0 @@
package com.ericampire.android.androidstudycase.presentation.screen.preview.business
sealed interface PreviewEffect
@@ -1,27 +1,12 @@
package com.ericampire.android.androidstudycase.presentation.screen.preview.business
import androidx.lifecycle.viewModelScope
import com.ericampire.android.androidstudycase.util.mvi.BaseViewModel
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import org.orbitmvi.orbit.Container
import org.orbitmvi.orbit.viewmodel.container
import javax.inject.Inject
@HiltViewModel
class PreviewViewModel @Inject constructor(
) : BaseViewModel<PreviewViewState, PreviewEffect, PreviewAction>() {
) : ViewModel() {
override val container: Container<PreviewViewState, PreviewEffect>
get() = container(initialState = PreviewViewState())
init {
viewModelScope.launch {
pendingAction.collectLatest { action ->
}
}
}
}
@@ -11,14 +11,10 @@ import com.budiyev.android.codescanner.CodeScanner
import com.budiyev.android.codescanner.CodeScannerView
import com.ericampire.android.androidstudycase.R
import com.ericampire.android.androidstudycase.presentation.custom.LottiePreviewDialog
import com.ericampire.android.androidstudycase.presentation.screen.preview.business.PreviewViewModel
@Composable
fun PreviewScreen(
navController: NavController,
viewModel: PreviewViewModel
) {
fun PreviewScreen(navController: NavController) {
var codeScanner: CodeScanner? = null
var lottieFileUrl by remember { mutableStateOf("") }
@@ -14,4 +14,5 @@ object AppColor {
val Black001 = Color(0xFF222222)
val BlackOverlay = Color(0x4D000000)
val BlackOverlay001 = Color(0x1A000000)
val WhiteTransparent = Color(0x80FFFFFF)
}
@@ -7,4 +7,5 @@ sealed class Destination(val route: String, @StringRes val resourceId: Int) {
object Home : Destination("home", R.string.txt_home)
object Explore : Destination("explore", R.string.txt_explore)
object Preview : Destination("preview", R.string.txt_preview)
object Login : Destination("login", R.string.txt_login)
}
+1
View File
@@ -1,2 +1,3 @@
<resources>
<string name="txt_featured_animation">Featured Animations</string>
</resources>
+1 -1
View File
@@ -1,5 +1,5 @@
object Apps {
const val compileSdk = 30
const val compileSdk = 31
const val buildToolsVersion = "30.0.3"
const val applicationId = "com.ericampire.android.androidstudycase"
+1 -1
View File
@@ -18,7 +18,7 @@ object Libs {
const val activity_compose = "androidx.activity:activity-compose:_"
const val startup_runtime = "androidx.startup:startup-runtime:_"
const val navigation_compose = "androidx.navigation:navigation-compose:2.4.0-alpha06"
const val navigation_compose = "androidx.navigation:navigation-compose:2.4.0-alpha08"
const val appcompat = "androidx.appcompat:appcompat:_"
const val preference_ktx = "androidx.preference:preference-ktx:_"
-2
View File
@@ -36,8 +36,6 @@ dependencies {
api(platform(Libs.kotlin_coroutine_bom))
api(Libs.kotlin_coroutine_core)
api(Libs.ktor_client_android)
testImplementation(Libs.junit_jupiter_api)
testImplementation(Libs.junit_jupiter_engine)
@@ -17,8 +17,11 @@ class RemoteAnimatorDataSource @Inject constructor(
override fun findAll(): Flow<Result<List<Animator>>> {
return flow {
try {
val data = httpClient.get<AnimatorApiResponse>(ApiUrl.Animator.featured)
emit(Result.Success(data.animatorAnimatorData.featuredAnimators.results))
val result = httpClient.get<AnimatorApiResponse>(ApiUrl.Animator.featured)
val identifiableData = result.data.animators.results.mapIndexed { index, animator ->
animator.copy(id = index.toLong())
}
emit(Result.Success(identifiableData))
} catch (e: Exception) {
emit(Result.Error(e))
}
@@ -17,12 +17,14 @@ class RemoteBlogDataSource @Inject constructor(
return flow {
try {
val data = httpClient.get<BlogApiResponse>(ApiUrl.Blog.latest)
emit(Result.Success(data.blogBlogData.blogPage.blogs))
val identifiableData = data.data.blogs.results.mapIndexed { index, blog ->
blog.copy(id = index.toLong())
}
emit(Result.Success(identifiableData))
} catch (e: Exception) {
emit(Result.Error(e))
}
}
}
override suspend fun save(blog: Blog) {
@@ -11,19 +11,19 @@ class LocalLottieFileDataSource @Inject constructor(
private val lottieFileDao: LottieFilesDao
) : LottieFileDataSource {
override fun findRecent(): Flow<Result<List<Lottiefile>>> {
return lottieFileDao.findRecent().map {
return lottieFileDao.findByType("recent").map {
Result.Success(it)
}
}
override fun findPopular(): Flow<Result<List<Lottiefile>>> {
return lottieFileDao.findPopular().map {
return lottieFileDao.findByType("popular").map {
Result.Success(it)
}
}
override fun findFeatured(): Flow<Result<List<Lottiefile>>> {
return lottieFileDao.findFeatured().map {
return lottieFileDao.findByType("featured").map {
Result.Success(it)
}
}
@@ -7,6 +7,7 @@ import com.ericampire.android.androidstudycase.util.Result
import io.ktor.client.*
import io.ktor.client.request.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.FlowCollector
import kotlinx.coroutines.flow.flow
import javax.inject.Inject
@@ -14,11 +15,14 @@ class RemoteLottieFileDataSource @Inject constructor(
private val httpClient: HttpClient
) : LottieFileDataSource {
private fun find(url: String): Flow<Result<List<Lottiefile>>> {
private fun find(
url: String,
action: suspend FlowCollector<Result<List<Lottiefile>>>.(LottieFilesApiResponse) -> Unit
): Flow<Result<List<Lottiefile>>> {
return flow {
try {
val data = httpClient.get<LottieFilesApiResponse>(url)
emit(Result.Success(data.lottieFilesLottieFilesData.page.results))
action(data)
} catch (e: Exception) {
emit(Result.Error(e))
}
@@ -26,15 +30,24 @@ class RemoteLottieFileDataSource @Inject constructor(
}
override fun findRecent(): Flow<Result<List<Lottiefile>>> {
return find(ApiUrl.LottieFile.recent)
return find(ApiUrl.LottieFile.recent) { response ->
val animations = response.lottieFilesLottieFilesData.recent?.results ?: emptyList()
emit(Result.Success(animations))
}
}
override fun findPopular(): Flow<Result<List<Lottiefile>>> {
return find(ApiUrl.LottieFile.popular)
return find(ApiUrl.LottieFile.popular) { response ->
val animations = response.lottieFilesLottieFilesData.popular?.results ?: emptyList()
emit(Result.Success(animations))
}
}
override fun findFeatured(): Flow<Result<List<Lottiefile>>> {
return find(ApiUrl.LottieFile.featured)
return find(ApiUrl.LottieFile.featured) { response ->
val animations = response.lottieFilesLottieFilesData.featured?.results ?: emptyList()
emit(Result.Success(animations))
}
}
override suspend fun save(lottiefile: Lottiefile) {
@@ -15,10 +15,13 @@ import com.ericampire.android.androidstudycase.domain.repository.AnimatorReposit
import com.ericampire.android.androidstudycase.domain.repository.BlogRepository
import com.ericampire.android.androidstudycase.domain.repository.LottieFileRepository
import com.ericampire.android.androidstudycase.domain.repository.UserRepository
import com.ericampire.android.androidstudycase.util.IoDispatcher
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
@Module
@@ -29,10 +32,14 @@ object RepositoryModule {
fun provideAnimatorRepository(
localDataSource: LocalAnimatorDataSource,
remoteDataSource: RemoteAnimatorDataSource,
@IoDispatcher coroutineDispatcher: CoroutineDispatcher,
coroutineScope: CoroutineScope
): AnimatorRepository {
return AnimatorRepositoryImpl(
localDataSource = localDataSource,
remoteDataSource = remoteDataSource
remoteDataSource = remoteDataSource,
coroutineDispatcher = coroutineDispatcher,
coroutineScope = coroutineScope
)
}
@@ -49,10 +56,14 @@ object RepositoryModule {
fun provideLottieFileRepository(
localDataSource: LocalLottieFileDataSource,
remoteDataSource: RemoteLottieFileDataSource,
@IoDispatcher coroutineDispatcher: CoroutineDispatcher,
coroutineScope: CoroutineScope
): LottieFileRepository {
return LottieFileRepositoryImpl(
localDataSource = localDataSource,
remoteDataSource = remoteDataSource
remoteDataSource = remoteDataSource,
coroutineDispatcher = coroutineDispatcher,
coroutineScope = coroutineScope
)
}
@@ -60,10 +71,14 @@ object RepositoryModule {
fun provideBlogRepository(
localDataSource: LocalBlogDataSource,
remoteDataSource: RemoteBlogDataSource,
@IoDispatcher coroutineDispatcher: CoroutineDispatcher,
coroutineScope: CoroutineScope
) : BlogRepository {
return BlogRepositoryImpl(
localDataSource = localDataSource,
remoteDataSource = remoteDataSource
remoteDataSource = remoteDataSource,
coroutineDispatcher = coroutineDispatcher,
coroutineScope = coroutineScope
)
}
}
@@ -3,15 +3,21 @@ package com.ericampire.android.androidstudycase.data.repository
import com.ericampire.android.androidstudycase.data.datasource.animator.AnimatorDataSource
import com.ericampire.android.androidstudycase.domain.entity.Animator
import com.ericampire.android.androidstudycase.domain.repository.AnimatorRepository
import com.ericampire.android.androidstudycase.util.IoDispatcher
import com.ericampire.android.androidstudycase.util.Result
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import javax.inject.Inject
class AnimatorRepositoryImpl @Inject constructor(
private val remoteDataSource: AnimatorDataSource,
private val localDataSource: AnimatorDataSource,
private val coroutineScope: CoroutineScope,
@IoDispatcher private val coroutineDispatcher: CoroutineDispatcher
) : AnimatorRepository {
override fun findAll(): Flow<Result<List<Animator>>> {
refreshData()
@@ -19,7 +25,7 @@ class AnimatorRepositoryImpl @Inject constructor(
}
private fun refreshData() {
suspend {
coroutineScope.launch(coroutineDispatcher) {
remoteDataSource.findAll().collect {
if (it is Result.Success) {
it.data.forEach { animator ->
@@ -3,14 +3,20 @@ package com.ericampire.android.androidstudycase.data.repository
import com.ericampire.android.androidstudycase.data.datasource.blog.BlogDataSource
import com.ericampire.android.androidstudycase.domain.entity.Blog
import com.ericampire.android.androidstudycase.domain.repository.BlogRepository
import com.ericampire.android.androidstudycase.util.IoDispatcher
import com.ericampire.android.androidstudycase.util.Result
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import javax.inject.Inject
class BlogRepositoryImpl @Inject constructor(
private val remoteDataSource: BlogDataSource,
private val localDataSource: BlogDataSource,
private val coroutineScope: CoroutineScope,
@IoDispatcher private val coroutineDispatcher: CoroutineDispatcher
) : BlogRepository {
override fun findAll(): Flow<Result<List<Blog>>> {
refreshData()
@@ -18,7 +24,7 @@ class BlogRepositoryImpl @Inject constructor(
}
private fun refreshData() {
suspend {
coroutineScope.launch(coroutineDispatcher) {
remoteDataSource.findAll().collect {
if (it is Result.Success) {
it.data.forEach { blog ->
@@ -3,41 +3,44 @@ package com.ericampire.android.androidstudycase.data.repository
import com.ericampire.android.androidstudycase.data.datasource.lottiefiles.LottieFileDataSource
import com.ericampire.android.androidstudycase.domain.entity.Lottiefile
import com.ericampire.android.androidstudycase.domain.repository.LottieFileRepository
import com.ericampire.android.androidstudycase.util.IoDispatcher
import com.ericampire.android.androidstudycase.util.Result
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import javax.inject.Inject
class LottieFileRepositoryImpl @Inject constructor(
private val localDataSource: LottieFileDataSource,
private val remoteDataSource: LottieFileDataSource,
private val coroutineScope: CoroutineScope,
@IoDispatcher private val coroutineDispatcher: CoroutineDispatcher
) : LottieFileRepository {
override fun findRecent(): Flow<Result<List<Lottiefile>>> {
val recentFiles = remoteDataSource.findRecent()
refreshData(recentFiles)
return recentFiles
refreshData(remoteDataSource.findRecent(), "recent")
return localDataSource.findRecent()
}
private fun refreshData(data: Flow<Result<List<Lottiefile>>>) {
suspend {
private fun refreshData(data: Flow<Result<List<Lottiefile>>>, type: String) {
coroutineScope.launch(coroutineDispatcher) {
data.collect {
if (it is Result.Success) {
it.data.forEach { file -> localDataSource.save(file) }
it.data.forEach { file -> localDataSource.save(file.copy(type = type)) }
}
}
}
}
override fun findPopular(): Flow<Result<List<Lottiefile>>> {
val files = remoteDataSource.findPopular()
refreshData(files)
return files
refreshData(remoteDataSource.findPopular(), "popular")
return localDataSource.findPopular()
}
override fun findFeatured(): Flow<Result<List<Lottiefile>>> {
val files = remoteDataSource.findFeatured()
refreshData(files)
return files
refreshData(remoteDataSource.findFeatured(), "featured")
return localDataSource.findFeatured()
}
}
@@ -12,12 +12,6 @@ interface LottieFilesDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun save(lottiefile: Lottiefile)
@Query("SELECT * FROM Lottiefile")
fun findPopular(): Flow<List<Lottiefile>>
@Query("SELECT * FROM Lottiefile")
fun findFeatured(): Flow<List<Lottiefile>>
@Query("SELECT * FROM Lottiefile")
fun findRecent(): Flow<List<Lottiefile>>
@Query("SELECT * FROM Lottiefile WHERE type = :type")
fun findByType(type: String): Flow<List<Lottiefile>>
}
+4 -3
View File
@@ -1,9 +1,7 @@
import de.fayard.refreshVersions.core.versionFor
plugins {
id("com.android.library")
id("kotlin-android")
kotlin("plugin.serialization") version "1.5.20"
kotlin("plugin.serialization") version "1.5.21"
kotlin("kapt")
}
@@ -41,6 +39,9 @@ dependencies {
api(Libs.ktor_client_core)
api(Libs.ktor_serialization)
api(Libs.ktor_client_android)
api(Libs.ktor_client_cio)
api(Libs.joda_time)
testImplementation(Libs.junit_jupiter_api)
@@ -1,5 +1,6 @@
package com.ericampire.android.androidstudycase.domain.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import kotlinx.serialization.SerialName
@@ -9,8 +10,10 @@ import kotlinx.serialization.Serializable
@Serializable
@Entity
data class Animator(
@ColumnInfo(name = "id_animator")
@PrimaryKey
var name: String = "",
val id: Long? = null,
val name: String = "",
var avatarUrl: String = "",
)
@@ -19,10 +22,11 @@ data class FeaturedAnimators(val results: List<Animator>)
@Serializable
data class AnimatorData(
val featuredAnimators: FeaturedAnimators
@SerialName("featuredAnimators")
val animators: FeaturedAnimators
)
@Serializable
data class AnimatorApiResponse(
@SerialName("data") val animatorAnimatorData: AnimatorData
@SerialName("data") val data: AnimatorData
)
@@ -2,20 +2,14 @@ package com.ericampire.android.androidstudycase.domain.entity
import androidx.room.Entity
import androidx.room.PrimaryKey
import androidx.room.TypeConverters
import com.ericampire.android.androidstudycase.domain.util.DateSerializer
import com.ericampire.android.androidstudycase.util.room.DateConverter
import kotlinx.serialization.Contextual
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.util.*
@Serializable
data class BlogPage(
val currentPage: Int,
val from: Int,
val perPage: Int,
val blogs: List<Blog>,
val results: List<Blog>,
val to: Int,
val total: Int,
val totalPages: Int
@@ -23,20 +17,19 @@ data class BlogPage(
@Serializable
data class BlogData(
@SerialName("blogs") val blogPage: BlogPage
val blogs: BlogPage
)
@Serializable
data class BlogApiResponse(
@SerialName("data") val blogBlogData: BlogData
val data: BlogData
)
@Serializable
@Entity
data class Blog(
// @TypeConverters(DateConverter::class)
// @Serializable(with = DateSerializer::class)
var postedAt: String = "",
@PrimaryKey val imageUrl: String = "",
var title: String
@PrimaryKey val id: Long? = null,
val postedAt: String = "",
val imageUrl: String = "",
val title: String
)
@@ -13,10 +13,14 @@ data class LottieFilesApiResponse(
val lottieFilesLottieFilesData: LottieFilesData
)
/**
* You can avoid this duplication using @JsonNames annotation
*/
@Serializable
data class LottieFilesData(
@SerialName("recent")
val page: LottieFilesPage
val recent: LottieFilesPage? = null,
val featured: LottieFilesPage? = null,
val popular: LottieFilesPage? = null,
)
@@ -40,6 +44,7 @@ data class Lottiefile(
var lottieUrl: String = "",
var gifUrl: String? = "",
var videoUrl: String? = "",
var type: String = "", // For making difference between recent, popular and featured
var imageUrl: String? = "",
@ColumnInfo(name = "file_name") var name: String = "",
var createdAt: String = "",
+2
View File
@@ -14,4 +14,6 @@
<string name="txt_go_to_explore">Go to Explore</string>
<string name="txt_login">Login</string>
<string name="txt_hello_stranger">Hello Stranger</string>
<string name="txt_featured_animator">Featured Animators</string>
<string name="txt_latest_story">Latest Stories</string>
</resources>
+1 -1
View File
@@ -40,7 +40,7 @@ dependencies {
api(platform(Libs.kotlin_coroutine_bom))
api(Libs.kotlin_coroutine_core)
api(Libs.orbit_mvi_core)
api(Libs.mavericks_core)
testImplementation(Libs.junit_jupiter_api)
testImplementation(Libs.junit_jupiter_engine)
@@ -1,12 +1,13 @@
package com.ericampire.android.androidstudycase.util.mvi
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.airbnb.mvrx.MavericksState
import com.airbnb.mvrx.MavericksViewModel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.launch
import org.orbitmvi.orbit.ContainerHost
abstract class BaseViewModel<S: Any, E: Any, A> : ContainerHost<S, E>, ViewModel() {
abstract class BaseViewModel<S : MavericksState, A>(
initialState: S
) : MavericksViewModel<S>(initialState) {
protected val pendingAction = MutableSharedFlow<A>()
fun submitAction(action: A) {
+7 -8
View File
@@ -11,14 +11,13 @@ plugin.android=7.0.2
version.androidx.activity=1.3.0-alpha08
version.androidx.preference=1.1.1
version.androidx.appcompat=1.3.0
version.androidx.compose.compiler=1.0.1
version.androidx.compose.material-icons-extended=1.0.1
version.androidx.compose.material=1.0.1
version.androidx.compose.runtime=1.0.1
version.google.accompanist=0.16.1
version.androidx.compose.ui=1.0.1
version.androidx.compose.ui-viewbinding=1.0.1
version.androidx.compose.compiler=1.0.2
version.androidx.compose.material-icons-extended=1.0.2
version.androidx.compose.material=1.0.2
version.androidx.compose.runtime=1.0.2
version.google.accompanist=0.18.0
version.androidx.compose.ui=1.0.2
version.androidx.compose.ui-viewbinding=1.0.2
version.androidx.core=1.5.0
version.androidx.room=2.3.0
version.androidx.startup=1.0.0