From 8d43411ef581c2f41544392e0b42cff498874bc1 Mon Sep 17 00:00:00 2001 From: LucasPrioste Date: Fri, 5 Apr 2024 17:27:19 +0100 Subject: [PATCH 01/24] base navigation --- .../pinkroom/marketsight/ui/MainActivity.kt | 44 ++++++++++--------- .../ui/core/navigation/BottomBarItem.kt | 21 +++++++++ .../ui/core/navigation/NavigationAppHost.kt | 33 ++++++++++++++ .../ui/core/navigation/NavigationBottomBar.kt | 10 +++++ .../marketsight/ui/core/navigation/Route.kt | 7 +++ .../marketsight/ui/{ => core}/theme/Color.kt | 2 +- .../ui/{ => core}/theme/Dimensions.kt | 2 +- .../marketsight/ui/{ => core}/theme/Theme.kt | 2 +- .../marketsight/ui/{ => core}/theme/Type.kt | 2 +- .../ui/detail_screen/DetailScreen.kt | 31 +++++++++++++ .../marketsight/ui/home_screen/HomeScreen.kt | 31 +++++++++++++ .../marketsight/ui/news_screen/NewsScreen.kt | 31 +++++++++++++ .../main/res/drawable/icon_filled_article.xml | 5 +++ .../main/res/drawable/icon_filled_home.xml | 5 +++ .../res/drawable/icon_outline_article.xml | 7 +++ .../main/res/drawable/icon_outline_home.xml | 5 +++ 16 files changed, 213 insertions(+), 25 deletions(-) create mode 100644 app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/BottomBarItem.kt create mode 100644 app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/NavigationAppHost.kt create mode 100644 app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/NavigationBottomBar.kt create mode 100644 app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/Route.kt rename app/src/main/java/dev/pinkroom/marketsight/ui/{ => core}/theme/Color.kt (87%) rename app/src/main/java/dev/pinkroom/marketsight/ui/{ => core}/theme/Dimensions.kt (97%) rename app/src/main/java/dev/pinkroom/marketsight/ui/{ => core}/theme/Theme.kt (97%) rename app/src/main/java/dev/pinkroom/marketsight/ui/{ => core}/theme/Type.kt (98%) create mode 100644 app/src/main/java/dev/pinkroom/marketsight/ui/detail_screen/DetailScreen.kt create mode 100644 app/src/main/java/dev/pinkroom/marketsight/ui/home_screen/HomeScreen.kt create mode 100644 app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsScreen.kt create mode 100644 app/src/main/res/drawable/icon_filled_article.xml create mode 100644 app/src/main/res/drawable/icon_filled_home.xml create mode 100644 app/src/main/res/drawable/icon_outline_article.xml create mode 100644 app/src/main/res/drawable/icon_outline_home.xml diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/MainActivity.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/MainActivity.kt index 4452a9a..3a8f129 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/MainActivity.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/MainActivity.kt @@ -6,15 +6,22 @@ import android.view.View import android.view.animation.LinearInterpolator import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import androidx.core.animation.doOnEnd import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen -import dev.pinkroom.marketsight.ui.theme.MarketSightTheme +import androidx.navigation.compose.rememberNavController +import dev.pinkroom.marketsight.ui.core.navigation.NavigationAppHost +import dev.pinkroom.marketsight.ui.core.navigation.Route +import dev.pinkroom.marketsight.ui.core.theme.MarketSightTheme class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -35,30 +42,25 @@ class MainActivity : ComponentActivity() { super.onCreate(savedInstanceState) setContent { + //val navController = rememberNavController() + MarketSightTheme { - // A surface container using the 'background' color from the theme Surface( modifier = Modifier.fillMaxSize(), ) { - Greeting("Android") + /*Scaffold( + modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.primary), + //bottomBar = + ) { padding -> + NavigationAppHost( + modifier = Modifier.padding(padding), + navController = navController, + startDestination = Route.NewsScreen + ) + }*/ + Text(text = "Hello", modifier = Modifier.fillMaxSize()) } } } } -} - -@Composable -fun Greeting(name: String, modifier: Modifier = Modifier) { - Text( - text = "Hello $name!", - modifier = modifier - ) -} - -@Preview(showBackground = true, showSystemUi = true) -@Composable -fun GreetingPreview() { - MarketSightTheme { - Greeting("Android") - } } \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/BottomBarItem.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/BottomBarItem.kt new file mode 100644 index 0000000..802b294 --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/BottomBarItem.kt @@ -0,0 +1,21 @@ +package dev.pinkroom.marketsight.ui.core.navigation + +import androidx.annotation.DrawableRes +import dev.pinkroom.marketsight.R + +sealed class BottomBarItem( + @DrawableRes val selectedIcon: Int, + @DrawableRes val unselectedIcon: Int, + val route: String, +){ + data object Home: BottomBarItem( + selectedIcon = R.drawable.icon_filled_home, + unselectedIcon = R.drawable.icon_outline_home, + route = Route.HomeScreen.route, + ) + data object News: BottomBarItem( + selectedIcon = R.drawable.icon_filled_article, + unselectedIcon = R.drawable.icon_outline_article, + route = Route.NewsScreen.route, + ) +} diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/NavigationAppHost.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/NavigationAppHost.kt new file mode 100644 index 0000000..dd035b1 --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/NavigationAppHost.kt @@ -0,0 +1,33 @@ +package dev.pinkroom.marketsight.ui.core.navigation + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import dev.pinkroom.marketsight.ui.detail_screen.DetailScreen +import dev.pinkroom.marketsight.ui.home_screen.HomeScreen +import dev.pinkroom.marketsight.ui.news_screen.NewsScreen + +@Composable +fun NavigationAppHost( + modifier: Modifier = Modifier, + navController: NavHostController, + startDestination: Route, +){ + NavHost( + navController = navController, + startDestination = startDestination.route, + modifier = modifier + ) { + composable(Route.HomeScreen.route) { + HomeScreen() + } + composable(Route.NewsScreen.route) { + NewsScreen() + } + composable(Route.DetailScreen.route) { + DetailScreen() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/NavigationBottomBar.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/NavigationBottomBar.kt new file mode 100644 index 0000000..5dc0e8f --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/NavigationBottomBar.kt @@ -0,0 +1,10 @@ +package dev.pinkroom.marketsight.ui.core.navigation + +import androidx.compose.runtime.Composable + +@Composable +fun NavigationBottomBar( + +){ + +} \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/Route.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/Route.kt new file mode 100644 index 0000000..5131c22 --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/Route.kt @@ -0,0 +1,7 @@ +package dev.pinkroom.marketsight.ui.core.navigation + +sealed class Route(val route: String){ + data object HomeScreen: Route("home-screen") + data object NewsScreen: Route("news-screen") + data object DetailScreen: Route("detail-screen") +} diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/theme/Color.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Color.kt similarity index 87% rename from app/src/main/java/dev/pinkroom/marketsight/ui/theme/Color.kt rename to app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Color.kt index f223094..3d49884 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/theme/Color.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Color.kt @@ -1,4 +1,4 @@ -package dev.pinkroom.marketsight.ui.theme +package dev.pinkroom.marketsight.ui.core.theme import androidx.compose.ui.graphics.Color diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/theme/Dimensions.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Dimensions.kt similarity index 97% rename from app/src/main/java/dev/pinkroom/marketsight/ui/theme/Dimensions.kt rename to app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Dimensions.kt index 8ed6b18..44e8722 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/theme/Dimensions.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Dimensions.kt @@ -1,4 +1,4 @@ -package dev.pinkroom.marketsight.ui.theme +package dev.pinkroom.marketsight.ui.core.theme import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalConfiguration diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/theme/Theme.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Theme.kt similarity index 97% rename from app/src/main/java/dev/pinkroom/marketsight/ui/theme/Theme.kt rename to app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Theme.kt index de2d2f6..83be03d 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/theme/Theme.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Theme.kt @@ -1,4 +1,4 @@ -package dev.pinkroom.marketsight.ui.theme +package dev.pinkroom.marketsight.ui.core.theme import android.app.Activity import android.os.Build diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/theme/Type.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Type.kt similarity index 98% rename from app/src/main/java/dev/pinkroom/marketsight/ui/theme/Type.kt rename to app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Type.kt index 9d82a5d..ba9b22c 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/theme/Type.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Type.kt @@ -1,4 +1,4 @@ -package dev.pinkroom.marketsight.ui.theme +package dev.pinkroom.marketsight.ui.core.theme import androidx.compose.material3.Typography import androidx.compose.runtime.Composable diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/detail_screen/DetailScreen.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/detail_screen/DetailScreen.kt new file mode 100644 index 0000000..4ff4581 --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/detail_screen/DetailScreen.kt @@ -0,0 +1,31 @@ +package dev.pinkroom.marketsight.ui.detail_screen + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview + +@Composable +fun DetailScreen( + modifier: Modifier = Modifier, +){ + Column( + modifier = modifier + .fillMaxSize(), + ) { + Text( + text = "Detail Screen", + ) + } +} + +@Preview( + showBackground = true, + showSystemUi = true, +) +@Composable +fun DetailScreenPreview(){ + DetailScreen() +} \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/home_screen/HomeScreen.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/home_screen/HomeScreen.kt new file mode 100644 index 0000000..87058cf --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/home_screen/HomeScreen.kt @@ -0,0 +1,31 @@ +package dev.pinkroom.marketsight.ui.home_screen + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview + +@Composable +fun HomeScreen( + modifier: Modifier = Modifier, +){ + Column( + modifier = modifier + .fillMaxSize(), + ) { + Text( + text = "Home Screen", + ) + } +} + +@Preview( + showBackground = true, + showSystemUi = true, +) +@Composable +fun HomeScreenPreview(){ + HomeScreen() +} \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsScreen.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsScreen.kt new file mode 100644 index 0000000..73c0698 --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsScreen.kt @@ -0,0 +1,31 @@ +package dev.pinkroom.marketsight.ui.news_screen + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview + +@Composable +fun NewsScreen( + modifier: Modifier = Modifier, +){ + Column( + modifier = modifier + .fillMaxSize(), + ) { + Text( + text = "News Screen", + ) + } +} + +@Preview( + showBackground = true, + showSystemUi = true, +) +@Composable +fun NewsScreenPreview(){ + NewsScreen() +} \ No newline at end of file diff --git a/app/src/main/res/drawable/icon_filled_article.xml b/app/src/main/res/drawable/icon_filled_article.xml new file mode 100644 index 0000000..30d5d26 --- /dev/null +++ b/app/src/main/res/drawable/icon_filled_article.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/icon_filled_home.xml b/app/src/main/res/drawable/icon_filled_home.xml new file mode 100644 index 0000000..20cb4d6 --- /dev/null +++ b/app/src/main/res/drawable/icon_filled_home.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/icon_outline_article.xml b/app/src/main/res/drawable/icon_outline_article.xml new file mode 100644 index 0000000..5844840 --- /dev/null +++ b/app/src/main/res/drawable/icon_outline_article.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/app/src/main/res/drawable/icon_outline_home.xml b/app/src/main/res/drawable/icon_outline_home.xml new file mode 100644 index 0000000..4d8dd31 --- /dev/null +++ b/app/src/main/res/drawable/icon_outline_home.xml @@ -0,0 +1,5 @@ + + + + + From 907f2ce38c449d3ca95b33b5482f7f135538a723 Mon Sep 17 00:00:00 2001 From: LucasPrioste Date: Mon, 8 Apr 2024 10:48:28 +0100 Subject: [PATCH 02/24] update colors background and add scaffold to support bottom bar --- .../pinkroom/marketsight/ui/MainActivity.kt | 32 +++---------------- .../marketsight/ui/core/theme/Theme.kt | 4 +++ 2 files changed, 8 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/MainActivity.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/MainActivity.kt index 192424d..094ca73 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/MainActivity.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/MainActivity.kt @@ -3,18 +3,11 @@ package dev.pinkroom.marketsight.ui import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface -import androidx.compose.material3.Text import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import androidx.core.animation.doOnEnd import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.navigation.compose.rememberNavController import dev.pinkroom.marketsight.ui.core.navigation.NavigationAppHost @@ -27,14 +20,14 @@ class MainActivity : ComponentActivity() { super.onCreate(savedInstanceState) setContent { - //val navController = rememberNavController() + val navController = rememberNavController() MarketSightTheme { Surface( modifier = Modifier.fillMaxSize(), ) { - /*Scaffold( - modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.primary), + Scaffold( + modifier = Modifier.fillMaxSize(), //bottomBar = ) { padding -> NavigationAppHost( @@ -42,26 +35,9 @@ class MainActivity : ComponentActivity() { navController = navController, startDestination = Route.NewsScreen ) - }*/ - Text(text = "Hello", modifier = Modifier.fillMaxSize()) + } } } } } -} - -@Composable -fun Greeting(name: String, modifier: Modifier = Modifier) { - Text( - text = "Hello $name!", - modifier = modifier - ) -} - -@Preview(showBackground = true, showSystemUi = true) -@Composable -fun GreetingPreview() { - MarketSightTheme { - Greeting("Android") - } } \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Theme.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Theme.kt index 83be03d..eb8cf37 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Theme.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Theme.kt @@ -21,6 +21,8 @@ private val DarkColorScheme = darkColorScheme( surfaceVariant = Gray, surface = WoodSmoke, onSurfaceVariant = White, + background = WoodSmoke, + onBackground = White, ) private val LightColorScheme = lightColorScheme( @@ -29,6 +31,8 @@ private val LightColorScheme = lightColorScheme( surfaceVariant = White, surface = GrayAthens, onSurfaceVariant = Black, + background = GrayAthens, + onBackground = Black, ) @Composable From 14c67a36906c869fa964b9686b2efa795d506a8e Mon Sep 17 00:00:00 2001 From: LucasPrioste Date: Mon, 8 Apr 2024 14:10:45 +0100 Subject: [PATCH 03/24] bottom bar --- .../pinkroom/marketsight/ui/MainActivity.kt | 15 ++- .../ui/core/navigation/BottomBarItem.kt | 4 + .../ui/core/navigation/NavigationBottomBar.kt | 119 ++++++++++++++++++ app/src/main/res/values/strings.xml | 3 + 4 files changed, 138 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/MainActivity.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/MainActivity.kt index 094ca73..5dc3de5 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/MainActivity.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/MainActivity.kt @@ -1,5 +1,6 @@ package dev.pinkroom.marketsight.ui +import android.annotation.SuppressLint import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -11,16 +12,19 @@ import androidx.compose.ui.Modifier import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.navigation.compose.rememberNavController import dev.pinkroom.marketsight.ui.core.navigation.NavigationAppHost +import dev.pinkroom.marketsight.ui.core.navigation.NavigationBottomBar import dev.pinkroom.marketsight.ui.core.navigation.Route import dev.pinkroom.marketsight.ui.core.theme.MarketSightTheme class MainActivity : ComponentActivity() { + @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") override fun onCreate(savedInstanceState: Bundle?) { installSplashScreen() super.onCreate(savedInstanceState) setContent { val navController = rememberNavController() + val startDestination = Route.HomeScreen MarketSightTheme { Surface( @@ -28,12 +32,17 @@ class MainActivity : ComponentActivity() { ) { Scaffold( modifier = Modifier.fillMaxSize(), - //bottomBar = - ) { padding -> + bottomBar = { + NavigationBottomBar( + navController = navController, + startDestination = startDestination, + ) + } + ) { padding -> NavigationAppHost( modifier = Modifier.padding(padding), navController = navController, - startDestination = Route.NewsScreen + startDestination = startDestination, ) } } diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/BottomBarItem.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/BottomBarItem.kt index 802b294..8968ef9 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/BottomBarItem.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/BottomBarItem.kt @@ -1,19 +1,23 @@ package dev.pinkroom.marketsight.ui.core.navigation import androidx.annotation.DrawableRes +import androidx.annotation.StringRes import dev.pinkroom.marketsight.R sealed class BottomBarItem( + @StringRes val title: Int, @DrawableRes val selectedIcon: Int, @DrawableRes val unselectedIcon: Int, val route: String, ){ data object Home: BottomBarItem( + title = R.string.home, selectedIcon = R.drawable.icon_filled_home, unselectedIcon = R.drawable.icon_outline_home, route = Route.HomeScreen.route, ) data object News: BottomBarItem( + title = R.string.news, selectedIcon = R.drawable.icon_filled_article, unselectedIcon = R.drawable.icon_outline_article, route = Route.NewsScreen.route, diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/NavigationBottomBar.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/NavigationBottomBar.kt index 5dc0e8f..4b164df 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/NavigationBottomBar.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/NavigationBottomBar.kt @@ -1,10 +1,129 @@ package dev.pinkroom.marketsight.ui.core.navigation +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarItem +import androidx.compose.material3.NavigationBarItemDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.navigation.NavHostController +import androidx.navigation.compose.currentBackStackEntryAsState +import dev.pinkroom.marketsight.R @Composable fun NavigationBottomBar( + navController: NavHostController, + startDestination: Route.HomeScreen, +){ + val items = listOf( + BottomBarItem.Home, + BottomBarItem.News, + ) + + var bottomBarState by rememberSaveable { + mutableStateOf(true) + } + val navBackStackEntry by navController.currentBackStackEntryAsState() + val currentRoute = navBackStackEntry?.destination?.route + + bottomBarState = when (currentRoute) { + Route.HomeScreen.route, Route.NewsScreen.route -> true + else -> false + } + AnimatedVisibility( + visible = bottomBarState, + enter = slideInVertically(initialOffsetY = { it }), + exit = slideOutVertically(targetOffsetY = { it }), + content = { + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 25.dp) + .padding(bottom = 15.dp), + shape = RoundedCornerShape(10.dp), + elevation = CardDefaults.cardElevation( + defaultElevation = 2.dp + ), + ) { + NavigationBar( + containerColor = Color.Transparent + ) { + items.forEach { bottomBarItem -> + AddItem( + currentRoute = currentRoute, + item = bottomBarItem, + onClick = { + navController.navigate(bottomBarItem.route) { + popUpTo(startDestination.route) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + } + ) + } + } + } + } + ) +} + +@Composable +fun RowScope.AddItem( + currentRoute: String?, + item: BottomBarItem, + onClick: () -> Unit, ){ + val isThisItemTheCurrentRoute = currentRoute == item.route + val icon = if (isThisItemTheCurrentRoute) item.selectedIcon + else item.unselectedIcon + val sizeIcon = animateDpAsState( + targetValue = if (isThisItemTheCurrentRoute) 38.dp else 24.dp, + animationSpec = tween( + durationMillis = 200, + ), + label = "Animation Size Icon Bottom Item", + ) + + val contentDesc = "${stringResource(id = item.title)} ${stringResource(id = R.string.desc_icon_bottom_bar)}" + NavigationBarItem( + selected = isThisItemTheCurrentRoute, + icon = { + Icon( + modifier = Modifier + .size(sizeIcon.value), + painter = painterResource(id = icon), + contentDescription = contentDesc, + tint = MaterialTheme.colorScheme.onPrimary + ) + }, + colors = NavigationBarItemDefaults.colors( + indicatorColor = Color.Transparent, + ), + onClick = onClick, + ) } \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 174c026..32fce48 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,6 @@ MarketSight + News + Home + icon bottom bar \ No newline at end of file From 388f4f22899cd7f0d7d3060dd1b5c5d5a21edf12 Mon Sep 17 00:00:00 2001 From: LucasPrioste Date: Mon, 8 Apr 2024 14:46:36 +0100 Subject: [PATCH 04/24] route with arg symbolId on Detail screen --- app/src/main/AndroidManifest.xml | 1 + .../ui/core/navigation/NavigationAppHost.kt | 20 ++++++++++++++++--- .../marketsight/ui/core/navigation/Route.kt | 13 +++++++++++- 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 48f45e9..a761a98 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -10,6 +10,7 @@ android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" + android:enableOnBackInvokedCallback="true" android:theme="@style/Theme.MarketSight.Splash" tools:targetApi="34" android:name=".MarketSightApp"> diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/NavigationAppHost.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/NavigationAppHost.kt index dd035b1..2682fcf 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/NavigationAppHost.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/NavigationAppHost.kt @@ -3,8 +3,11 @@ package dev.pinkroom.marketsight.ui.core.navigation import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.navigation.NavHostController +import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable +import androidx.navigation.navArgument +import dev.pinkroom.marketsight.ui.core.navigation.Args.SYMBOL_ID import dev.pinkroom.marketsight.ui.detail_screen.DetailScreen import dev.pinkroom.marketsight.ui.home_screen.HomeScreen import dev.pinkroom.marketsight.ui.news_screen.NewsScreen @@ -20,13 +23,24 @@ fun NavigationAppHost( startDestination = startDestination.route, modifier = modifier ) { - composable(Route.HomeScreen.route) { + composable( + route = Route.HomeScreen.route, + ) { HomeScreen() } - composable(Route.NewsScreen.route) { + composable( + route = Route.NewsScreen.route, + ) { NewsScreen() } - composable(Route.DetailScreen.route) { + composable( + route = Route.DetailScreen.route, + arguments = listOf( + navArgument(name = SYMBOL_ID){ + type = NavType.StringType + }, + ) + ) { DetailScreen() } } diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/Route.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/Route.kt index 5131c22..a21306a 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/Route.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/Route.kt @@ -1,7 +1,18 @@ package dev.pinkroom.marketsight.ui.core.navigation +import dev.pinkroom.marketsight.ui.core.navigation.Args.SYMBOL_ID + sealed class Route(val route: String){ data object HomeScreen: Route("home-screen") data object NewsScreen: Route("news-screen") - data object DetailScreen: Route("detail-screen") + data object DetailScreen: Route("detail-screen/{$SYMBOL_ID}"){ + fun withSymbol(symbolId: String): String { + return this.route.replace(oldValue = "{$SYMBOL_ID}", newValue = symbolId) + } + } } + + +object Args { + const val SYMBOL_ID = "symbolId" +} \ No newline at end of file From 398453d7273fa54413ddaf7c465d4f924ca440ac Mon Sep 17 00:00:00 2001 From: LucasPrioste Date: Mon, 8 Apr 2024 16:42:04 +0100 Subject: [PATCH 05/24] initial setup alpaca API --- app/build.gradle.kts | 21 +++++++- .../common/FlowStreamAdapterFactory.kt | 22 +++++++++ .../HelperIdentifierMessagesAlpacaService.kt | 12 +++++ .../dev/pinkroom/marketsight/common/Utils.kt | 5 ++ .../marketsight/data/remote/AlpacaApi.kt | 4 ++ .../marketsight/data/remote/AlpacaService.kt | 19 +++++++ .../data/remote/model/dto/MessageErrorDto.kt | 6 +++ .../data/remote/model/dto/NewsMessageDto.kt | 16 ++++++ .../model/dto/ResponseAlpacaServiceDto.kt | 5 ++ .../model/request/MessageAlpacaService.kt | 8 +++ .../dev/pinkroom/marketsight/di/AppModule.kt | 49 +++++++++++++++++++ .../domain/repository/NewsRepository.kt | 4 ++ gradle/libs.versions.toml | 5 +- 13 files changed, 174 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/dev/pinkroom/marketsight/common/FlowStreamAdapterFactory.kt create mode 100644 app/src/main/java/dev/pinkroom/marketsight/common/HelperIdentifierMessagesAlpacaService.kt create mode 100644 app/src/main/java/dev/pinkroom/marketsight/common/Utils.kt create mode 100644 app/src/main/java/dev/pinkroom/marketsight/data/remote/AlpacaApi.kt create mode 100644 app/src/main/java/dev/pinkroom/marketsight/data/remote/AlpacaService.kt create mode 100644 app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/MessageErrorDto.kt create mode 100644 app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/NewsMessageDto.kt create mode 100644 app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/ResponseAlpacaServiceDto.kt create mode 100644 app/src/main/java/dev/pinkroom/marketsight/data/remote/model/request/MessageAlpacaService.kt create mode 100644 app/src/main/java/dev/pinkroom/marketsight/domain/repository/NewsRepository.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ceec6cf..8e104d2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,3 +1,6 @@ +import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties +import java.util.Properties + plugins { kotlin("kapt") alias(libs.plugins.androidApplication) @@ -19,6 +22,18 @@ android { vectorDrawables { useSupportLibrary = true } + + val keystoreFile = project.rootProject.file("local.properties") + val properties = Properties() + properties.load(keystoreFile.inputStream()) + + val alpacaStreamUrl = properties.getProperty("ALPACA_STREAM_URL") ?: "" + val alpacaApiId = properties.getProperty("ALPACA_API_ID") ?: "" + val alpacaApiSecret = properties.getProperty("ALPACA_API_SECRET") ?: "" + + buildConfigField(type = "String", name = "ALPACA_STREAM_URL", value = alpacaStreamUrl) + buildConfigField(type = "String", name = "ALPACA_API_ID", value = alpacaApiId) + buildConfigField(type = "String", name = "ALPACA_API_SECRET", value = alpacaApiSecret) } buildTypes { @@ -39,6 +54,7 @@ android { } buildFeatures { compose = true + buildConfig = true } composeOptions { kotlinCompilerExtensionVersion = "1.5.10" @@ -72,7 +88,10 @@ dependencies { implementation(libs.okhttp.interceptor) // SCARLET - implementation(libs.scarlet) + implementation(libs.scarlet.core) + implementation(libs.scarlet.stream) + implementation(libs.scarlet.gson) + implementation(libs.scarlet.okhttp) // DAGGER HILT kapt(libs.hilt.compiler) diff --git a/app/src/main/java/dev/pinkroom/marketsight/common/FlowStreamAdapterFactory.kt b/app/src/main/java/dev/pinkroom/marketsight/common/FlowStreamAdapterFactory.kt new file mode 100644 index 0000000..c8dbde4 --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/common/FlowStreamAdapterFactory.kt @@ -0,0 +1,22 @@ +package dev.pinkroom.marketsight.common + +import com.tinder.scarlet.Stream +import com.tinder.scarlet.StreamAdapter +import com.tinder.scarlet.utils.getRawType +import kotlinx.coroutines.flow.Flow +import java.lang.reflect.Type +import kotlinx.coroutines.reactive.asFlow + +class FlowStreamAdapterFactory : StreamAdapter.Factory { + + override fun create(type: Type): StreamAdapter { + return when (type.getRawType()) { + Flow::class.java -> FlowStreamAdapter() + else -> throw IllegalArgumentException() + } + } +} + +private class FlowStreamAdapter : StreamAdapter> where T : Any { + override fun adapt(stream: Stream) = stream.asFlow() +} \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/common/HelperIdentifierMessagesAlpacaService.kt b/app/src/main/java/dev/pinkroom/marketsight/common/HelperIdentifierMessagesAlpacaService.kt new file mode 100644 index 0000000..db52467 --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/common/HelperIdentifierMessagesAlpacaService.kt @@ -0,0 +1,12 @@ +package dev.pinkroom.marketsight.common + +sealed class HelperIdentifierMessagesAlpacaService( + val identifier: String, +){ + data object News: HelperIdentifierMessagesAlpacaService(identifier = "n") + data object Stocks: HelperIdentifierMessagesAlpacaService(identifier = "q") + data object Crypto: HelperIdentifierMessagesAlpacaService(identifier = "t") + data object Success: HelperIdentifierMessagesAlpacaService(identifier = "success") + data object Error: HelperIdentifierMessagesAlpacaService(identifier = "error") + data object Subscription: HelperIdentifierMessagesAlpacaService(identifier = "subscription") +} diff --git a/app/src/main/java/dev/pinkroom/marketsight/common/Utils.kt b/app/src/main/java/dev/pinkroom/marketsight/common/Utils.kt new file mode 100644 index 0000000..40157db --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/common/Utils.kt @@ -0,0 +1,5 @@ +package dev.pinkroom.marketsight.common + +import okhttp3.OkHttpClient +import okhttp3.logging.HttpLoggingInterceptor + diff --git a/app/src/main/java/dev/pinkroom/marketsight/data/remote/AlpacaApi.kt b/app/src/main/java/dev/pinkroom/marketsight/data/remote/AlpacaApi.kt new file mode 100644 index 0000000..b134966 --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/data/remote/AlpacaApi.kt @@ -0,0 +1,4 @@ +package dev.pinkroom.marketsight.data.remote + +interface AlpacaApi { +} \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/data/remote/AlpacaService.kt b/app/src/main/java/dev/pinkroom/marketsight/data/remote/AlpacaService.kt new file mode 100644 index 0000000..ad8659a --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/data/remote/AlpacaService.kt @@ -0,0 +1,19 @@ +package dev.pinkroom.marketsight.data.remote + +import com.tinder.scarlet.WebSocket +import com.tinder.scarlet.ws.Receive +import com.tinder.scarlet.ws.Send +import dev.pinkroom.marketsight.data.remote.model.dto.ResponseAlpacaServiceDto +import dev.pinkroom.marketsight.data.remote.model.request.MessageAlpacaService +import kotlinx.coroutines.flow.Flow + +interface AlpacaService { + @Send + fun sendSubscribe(message: MessageAlpacaService) + + @Receive + fun observeResponse(): Flow + + @Receive + fun observeOnConnectionEvent(): Flow +} \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/MessageErrorDto.kt b/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/MessageErrorDto.kt new file mode 100644 index 0000000..aa322c5 --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/MessageErrorDto.kt @@ -0,0 +1,6 @@ +package dev.pinkroom.marketsight.data.remote.model.dto + +data class MessageErrorDto( + val code: Int, + val msg: String, +) diff --git a/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/NewsMessageDto.kt b/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/NewsMessageDto.kt new file mode 100644 index 0000000..10a515f --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/NewsMessageDto.kt @@ -0,0 +1,16 @@ +package dev.pinkroom.marketsight.data.remote.model.dto + +import com.google.gson.annotations.SerializedName + +data class NewsMessageDto( + val id: Int, + val headline: String, + val summary: String, + val author: String, + @SerializedName("created_at") val createdAt: String, + @SerializedName("updated_at") val updatedAt: String, + val url: String, + val content: String, + val symbols: List, + val source: String, +) diff --git a/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/ResponseAlpacaServiceDto.kt b/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/ResponseAlpacaServiceDto.kt new file mode 100644 index 0000000..d6a3ab3 --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/ResponseAlpacaServiceDto.kt @@ -0,0 +1,5 @@ +package dev.pinkroom.marketsight.data.remote.model.dto + +data class ResponseAlpacaServiceDto( + val data: List = emptyList(), +) \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/request/MessageAlpacaService.kt b/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/request/MessageAlpacaService.kt new file mode 100644 index 0000000..7e3cd8a --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/request/MessageAlpacaService.kt @@ -0,0 +1,8 @@ +package dev.pinkroom.marketsight.data.remote.model.request + +data class MessageAlpacaService( + val action: String, // subscribe / unsubscribe + val trades: List = emptyList(), + val quotes: List = emptyList(), + val news: List = emptyList(), +) diff --git a/app/src/main/java/dev/pinkroom/marketsight/di/AppModule.kt b/app/src/main/java/dev/pinkroom/marketsight/di/AppModule.kt index 170ab50..3f63942 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/di/AppModule.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/di/AppModule.kt @@ -1,4 +1,53 @@ package dev.pinkroom.marketsight.di +import com.tinder.scarlet.Scarlet +import com.tinder.scarlet.messageadapter.gson.GsonMessageAdapter +import com.tinder.scarlet.websocket.okhttp.newWebSocketFactory +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dev.pinkroom.marketsight.BuildConfig +import dev.pinkroom.marketsight.common.FlowStreamAdapterFactory +import dev.pinkroom.marketsight.data.remote.AlpacaService +import okhttp3.OkHttpClient +import java.util.concurrent.TimeUnit +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) object AppModule { + + private const val ALPACA_STREAM_URL_NEWS = BuildConfig.ALPACA_STREAM_URL + "v1beta1/news" + private const val API_TIMEOUT = 60L + @Provides + @Singleton + fun provideOkHttpClient() = OkHttpClient.Builder() + .connectTimeout(API_TIMEOUT, TimeUnit.SECONDS) + .readTimeout(API_TIMEOUT, TimeUnit.SECONDS) + .writeTimeout(API_TIMEOUT, TimeUnit.SECONDS) + .addInterceptor { chain -> + val original = chain.request() + + // Request customization: add request headers + val requestBuilder = original.newBuilder() + .addHeader("APCA-API-KEY-ID", BuildConfig.ALPACA_API_ID) + .addHeader("APCA-API-SECRET-KEY", BuildConfig.ALPACA_API_SECRET) + + val request = requestBuilder.build() + chain.proceed(request) + } + .build() + + @Provides + @Singleton + fun provideAlpacaNewsService(okHttpClient: OkHttpClient): AlpacaService { + val scarlet = Scarlet.Builder() + .webSocketFactory(okHttpClient.newWebSocketFactory(ALPACA_STREAM_URL_NEWS)) + .addMessageAdapterFactory(GsonMessageAdapter.Factory()) + .addStreamAdapterFactory(FlowStreamAdapterFactory()) + .build() + + return scarlet.create(AlpacaService::class.java) + } } \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/domain/repository/NewsRepository.kt b/app/src/main/java/dev/pinkroom/marketsight/domain/repository/NewsRepository.kt new file mode 100644 index 0000000..f7ff5cb --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/domain/repository/NewsRepository.kt @@ -0,0 +1,4 @@ +package dev.pinkroom.marketsight.domain.repository + +interface NewsRepository { +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0cf5180..ec3615f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -38,7 +38,10 @@ okhttp-core = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = " okhttp-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okHttpVersion" } # SCARLET -scarlet = { group = "com.tinder.scarlet", name = "scarlet", version.ref = "scarletVersion" } +scarlet-core = { group = "com.tinder.scarlet", name = "scarlet", version.ref = "scarletVersion" } +scarlet-stream = { group = "com.tinder.scarlet", name = "stream-adapter-coroutines", version.ref = "scarletVersion" } +scarlet-gson = { group = "com.tinder.scarlet", name = "message-adapter-gson", version.ref = "scarletVersion" } +scarlet-okhttp = { group = "com.tinder.scarlet", name = "websocket-okhttp", version.ref = "scarletVersion" } # DAGGER HILT hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hiltVersion" } From 3f5ed1fad6e020b6b17b58e4b76e41e5fa4c7122 Mon Sep 17 00:00:00 2001 From: LucasPrioste Date: Mon, 8 Apr 2024 16:52:15 +0100 Subject: [PATCH 06/24] update dimensions --- .../ui/core/navigation/NavigationBottomBar.kt | 12 +++++------ .../marketsight/ui/core/theme/Dimensions.kt | 20 +++++++++++++++++++ 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/NavigationBottomBar.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/NavigationBottomBar.kt index 4b164df..0359eb5 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/NavigationBottomBar.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/NavigationBottomBar.kt @@ -26,10 +26,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController import androidx.navigation.compose.currentBackStackEntryAsState import dev.pinkroom.marketsight.R +import dev.pinkroom.marketsight.ui.core.theme.dimens @Composable fun NavigationBottomBar( @@ -60,11 +60,11 @@ fun NavigationBottomBar( Card( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 25.dp) - .padding(bottom = 15.dp), - shape = RoundedCornerShape(10.dp), + .padding(horizontal = dimens.horizontalPadding) + .padding(bottom = dimens.menuBottomPadding), + shape = RoundedCornerShape(dimens.smallShape), elevation = CardDefaults.cardElevation( - defaultElevation = 2.dp + defaultElevation = dimens.lowElevation, ), ) { NavigationBar( @@ -101,7 +101,7 @@ fun RowScope.AddItem( val icon = if (isThisItemTheCurrentRoute) item.selectedIcon else item.unselectedIcon val sizeIcon = animateDpAsState( - targetValue = if (isThisItemTheCurrentRoute) 38.dp else 24.dp, + targetValue = if (isThisItemTheCurrentRoute) dimens.largeIconSize else dimens.normalIconSize, animationSpec = tween( durationMillis = 200, ), diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Dimensions.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Dimensions.kt index 44e8722..2b30d7a 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Dimensions.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Dimensions.kt @@ -31,6 +31,26 @@ class Dimensions( val normalPadding: Dp = 16.dp, val largePadding: Dp = 24.dp, val xLargePadding: Dp = 32.dp, + + // Icon + val normalIconSize: Dp = 24.dp, + val largeIconSize: Dp = 38.dp, + + // Elevation + val lowElevation: Dp = 2.dp, + val normalElevation: Dp = 5.dp, + val largeElevation: Dp = 8.dp, + + // Shape + val smallShape: Dp = 10.dp, + val normalShape: Dp = 15.dp, + val largeShape: Dp = 22.dp, + + // Page Padding + val horizontalPadding: Dp = 25.dp, + + // Others + val menuBottomPadding: Dp = 15.dp, ) val dimens: Dimensions From 31d797ea432f769ae6ec695d21039086fea8c5fe Mon Sep 17 00:00:00 2001 From: LucasPrioste Date: Wed, 10 Apr 2024 18:34:23 +0100 Subject: [PATCH 07/24] update alpaca news service --- app/build.gradle.kts | 10 ++- app/src/main/AndroidManifest.xml | 2 + .../pinkroom/marketsight/MarketSightApp.kt | 2 + .../marketsight/common/DefaultDispatchers.kt | 18 ++++ .../pinkroom/marketsight/common/GsonUtils.kt | 24 ++++++ .../HelperIdentifierMessagesAlpacaService.kt | 19 +++-- .../pinkroom/marketsight/common/Resource.kt | 13 +++ .../dev/pinkroom/marketsight/common/Utils.kt | 27 ++++++ .../data_source/AlpacaRemoteDataSource.kt | 85 +++++++++++++++++++ .../marketsight/data/mapper/RemoteMapper.kt | 48 +++++++++++ .../marketsight/data/remote/AlpacaService.kt | 9 +- ...{MessageErrorDto.kt => ErrorMessageDto.kt} | 2 +- .../data/remote/model/dto/NewsMessageDto.kt | 5 +- .../model/dto/ResponseAlpacaServiceDto.kt | 5 -- .../model/dto/SubscriptionMessageDto.kt | 7 ++ .../data/remote/model/dto/TypeMessageDto.kt | 7 ++ .../data/repository/NewsRepositoryImp.kt | 44 ++++++++++ .../dev/pinkroom/marketsight/di/AppModule.kt | 51 ++++++++--- .../marketsight/domain/model/ErrorMessage.kt | 6 ++ .../marketsight/domain/model/NewsInfo.kt | 15 ++++ .../domain/model/SubscriptionMessage.kt | 7 ++ .../domain/repository/NewsRepository.kt | 14 +++ .../pinkroom/marketsight/ui/MainActivity.kt | 4 +- .../ui/core/navigation/NavigationAppHost.kt | 3 + .../ui/home_screen/HomeViewModel.kt | 33 +++++++ build.gradle.kts | 2 + gradle/libs.versions.toml | 14 ++- 27 files changed, 439 insertions(+), 37 deletions(-) create mode 100644 app/src/main/java/dev/pinkroom/marketsight/common/DefaultDispatchers.kt create mode 100644 app/src/main/java/dev/pinkroom/marketsight/common/GsonUtils.kt create mode 100644 app/src/main/java/dev/pinkroom/marketsight/common/Resource.kt create mode 100644 app/src/main/java/dev/pinkroom/marketsight/data/data_source/AlpacaRemoteDataSource.kt create mode 100644 app/src/main/java/dev/pinkroom/marketsight/data/mapper/RemoteMapper.kt rename app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/{MessageErrorDto.kt => ErrorMessageDto.kt} (77%) delete mode 100644 app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/ResponseAlpacaServiceDto.kt create mode 100644 app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/SubscriptionMessageDto.kt create mode 100644 app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/TypeMessageDto.kt create mode 100644 app/src/main/java/dev/pinkroom/marketsight/data/repository/NewsRepositoryImp.kt create mode 100644 app/src/main/java/dev/pinkroom/marketsight/domain/model/ErrorMessage.kt create mode 100644 app/src/main/java/dev/pinkroom/marketsight/domain/model/NewsInfo.kt create mode 100644 app/src/main/java/dev/pinkroom/marketsight/domain/model/SubscriptionMessage.kt create mode 100644 app/src/main/java/dev/pinkroom/marketsight/ui/home_screen/HomeViewModel.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8e104d2..381da7e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,10 +1,11 @@ -import com.android.build.gradle.internal.cxx.configure.gradleLocalProperties import java.util.Properties plugins { kotlin("kapt") alias(libs.plugins.androidApplication) alias(libs.plugins.jetbrainsKotlinAndroid) + alias(libs.plugins.hiltAndroid) + alias(libs.plugins.jsonSerialization) } android { @@ -46,6 +47,7 @@ android { } } compileOptions { + isCoreLibraryDesugaringEnabled = true sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } @@ -68,6 +70,7 @@ android { dependencies { // CORE + coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4") implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.ui) @@ -92,6 +95,8 @@ dependencies { implementation(libs.scarlet.stream) implementation(libs.scarlet.gson) implementation(libs.scarlet.okhttp) + implementation(libs.scarlet.lifecycle) + implementation(libs.scarlet.moshi) // DAGGER HILT kapt(libs.hilt.compiler) @@ -101,6 +106,9 @@ dependencies { // SPLASH implementation(libs.splash) + // JSON + implementation(libs.json) + // UNIT TEST testImplementation(libs.junit) testImplementation(libs.junit.api) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a761a98..6396822 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,8 @@ + + Gson.toJsonValue(value: Any?, classOfT: Class): T = fromJson(toJson(value), classOfT) + +fun Gson.toObject(value: Any, helperIdentifier: HelperIdentifierMessagesAlpacaService): T? { + val typeMessage = toJsonValue(value, TypeMessageDto::class.java) + return if (typeMessage.value == helperIdentifier.identifier && helperIdentifier.classOfT != null){ + toJsonValue(value, helperIdentifier.classOfT) + } else null +} + +fun Gson.verifyIfIsError(jsonValue: String): ErrorMessageDto? { + val helperIdentifier = HelperIdentifierMessagesAlpacaService.Error + fromJson(jsonValue, Array::class.java).toList().forEach { data -> + toObject(value = data, helperIdentifier = helperIdentifier)?.let { + return it + } + } + return null +} diff --git a/app/src/main/java/dev/pinkroom/marketsight/common/HelperIdentifierMessagesAlpacaService.kt b/app/src/main/java/dev/pinkroom/marketsight/common/HelperIdentifierMessagesAlpacaService.kt index db52467..6205a35 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/common/HelperIdentifierMessagesAlpacaService.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/common/HelperIdentifierMessagesAlpacaService.kt @@ -1,12 +1,17 @@ package dev.pinkroom.marketsight.common -sealed class HelperIdentifierMessagesAlpacaService( +import dev.pinkroom.marketsight.data.remote.model.dto.ErrorMessageDto +import dev.pinkroom.marketsight.data.remote.model.dto.NewsMessageDto +import dev.pinkroom.marketsight.data.remote.model.dto.SubscriptionMessageDto + +sealed class HelperIdentifierMessagesAlpacaService( val identifier: String, + val classOfT: Class? = null, ){ - data object News: HelperIdentifierMessagesAlpacaService(identifier = "n") - data object Stocks: HelperIdentifierMessagesAlpacaService(identifier = "q") - data object Crypto: HelperIdentifierMessagesAlpacaService(identifier = "t") - data object Success: HelperIdentifierMessagesAlpacaService(identifier = "success") - data object Error: HelperIdentifierMessagesAlpacaService(identifier = "error") - data object Subscription: HelperIdentifierMessagesAlpacaService(identifier = "subscription") + data object News: HelperIdentifierMessagesAlpacaService(identifier = "n", classOfT = NewsMessageDto::class.java) + data object Stocks: HelperIdentifierMessagesAlpacaService(identifier = "q") + data object Crypto: HelperIdentifierMessagesAlpacaService(identifier = "t") + data object Success: HelperIdentifierMessagesAlpacaService(identifier = "success") + data object Error: HelperIdentifierMessagesAlpacaService(identifier = "error", classOfT = ErrorMessageDto::class.java) + data object Subscription: HelperIdentifierMessagesAlpacaService(identifier = "subscription", classOfT = SubscriptionMessageDto::class.java) } diff --git a/app/src/main/java/dev/pinkroom/marketsight/common/Resource.kt b/app/src/main/java/dev/pinkroom/marketsight/common/Resource.kt new file mode 100644 index 0000000..c82f995 --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/common/Resource.kt @@ -0,0 +1,13 @@ +package dev.pinkroom.marketsight.common + +import dev.pinkroom.marketsight.domain.model.ErrorMessage + +sealed class Resource{ + data class Success(val data: T?): Resource() + data class Error( + val message: String? = null, + val errorInfo: ErrorMessage? = null, + val data: T? = null + ): Resource() + data class Loading(val isLoading: Boolean=true): Resource() +} \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/common/Utils.kt b/app/src/main/java/dev/pinkroom/marketsight/common/Utils.kt index 40157db..5c285c3 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/common/Utils.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/common/Utils.kt @@ -1,5 +1,32 @@ package dev.pinkroom.marketsight.common +import dev.pinkroom.marketsight.BuildConfig import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor +fun OkHttpClient.Builder.addAuthenticationInterceptor(): OkHttpClient.Builder { + addInterceptor { chain -> + val original = chain.request() + val requestBuilder = original.newBuilder() + .addHeader("APCA-API-KEY-ID", BuildConfig.ALPACA_API_ID) + .addHeader("APCA-API-SECRET-KEY", BuildConfig.ALPACA_API_SECRET) + + val request = requestBuilder.build() + chain.proceed(request) + } + return this +} + +fun OkHttpClient.Builder.addLoggingInterceptor(): OkHttpClient.Builder { + if (BuildConfig.DEBUG) { + val loggingInterceptor = HttpLoggingInterceptor() + loggingInterceptor.level = HttpLoggingInterceptor.Level.BODY + addInterceptor(loggingInterceptor) + } + return this +} + +sealed class ActionAlpaca(val action: String) { + data object Subscribe: ActionAlpaca(action = "subscribe") + data object Unsubscribe: ActionAlpaca(action = "unsubscribe") +} \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/data/data_source/AlpacaRemoteDataSource.kt b/app/src/main/java/dev/pinkroom/marketsight/data/data_source/AlpacaRemoteDataSource.kt new file mode 100644 index 0000000..494501c --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/data/data_source/AlpacaRemoteDataSource.kt @@ -0,0 +1,85 @@ +package dev.pinkroom.marketsight.data.data_source + +import android.util.Log +import com.google.gson.Gson +import com.tinder.scarlet.Message +import com.tinder.scarlet.WebSocket +import dev.pinkroom.marketsight.common.ActionAlpaca +import dev.pinkroom.marketsight.common.DispatcherProvider +import dev.pinkroom.marketsight.common.HelperIdentifierMessagesAlpacaService +import dev.pinkroom.marketsight.common.Resource +import dev.pinkroom.marketsight.common.toObject +import dev.pinkroom.marketsight.common.verifyIfIsError +import dev.pinkroom.marketsight.data.mapper.toErrorMessage +import dev.pinkroom.marketsight.data.mapper.toNewsInfo +import dev.pinkroom.marketsight.data.mapper.toSubscriptionMessage +import dev.pinkroom.marketsight.data.remote.AlpacaService +import dev.pinkroom.marketsight.data.remote.model.dto.NewsMessageDto +import dev.pinkroom.marketsight.data.remote.model.request.MessageAlpacaService +import dev.pinkroom.marketsight.domain.model.NewsInfo +import dev.pinkroom.marketsight.domain.model.SubscriptionMessage +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.take +import javax.inject.Inject + +class AlpacaRemoteDataSource @Inject constructor( + private val gson: Gson, + private val alpacaService: AlpacaService, + private val dispatchers: DispatcherProvider, +) { + fun subscribeNews(symbols: List = listOf("*")): Flow> = flow> { + alpacaService.observeOnConnectionEvent().collect{ + when(it){ + is WebSocket.Event.OnConnectionOpened<*> -> { + alpacaService.sendSubscribe(message = MessageAlpacaService(action = ActionAlpaca.Subscribe.action, news = symbols)) + } + is WebSocket.Event.OnMessageReceived -> { + Log.d(TAG,"Received: ${it.message}") + if (it.message is Message.Text){ + gson.verifyIfIsError(jsonValue = (it.message as Message.Text).value)?.let { errorMessage -> + emit(Resource.Error(message = errorMessage.msg, errorInfo = errorMessage.toErrorMessage())) + } ?: run { + emit(Resource.Success(it)) + } + } + } + is WebSocket.Event.OnConnectionFailed -> { + emit(Resource.Error(message = it.throwable.message, data = it)) + } + else -> Log.d(TAG,it.toString()) + } + } + }.flowOn(dispatchers.IO) + + fun getRealTimeNews(): Flow>> = flow { + alpacaService.observeResponse().collect{ data -> + Log.d("TESTE","AQUI SEMPRE") + val listNews = mutableListOf() + data.forEach { + gson.toObject(value = it, helperIdentifier = HelperIdentifierMessagesAlpacaService.News)?.let { news -> + listNews.add(news) + } + } + emit(Resource.Success(listNews.map { it.toNewsInfo() })) + } + }.flowOn(dispatchers.IO) + + fun sendMessageToAlpacaService(message: MessageAlpacaService): Flow> = flow> { + alpacaService.sendSubscribe(message = message) + alpacaService.observeResponse().collect { data -> + data.forEach { + gson.toObject(value = it, helperIdentifier = HelperIdentifierMessagesAlpacaService.Subscription)?.let { sub -> + emit(Resource.Success(sub.toSubscriptionMessage())) + } ?: run { + emit(Resource.Error(message = "Something went wrong on subscribe")) + } + } + } + }.flowOn(dispatchers.IO).take(1) + + companion object { + const val TAG = "AlpacaRemote" + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/data/mapper/RemoteMapper.kt b/app/src/main/java/dev/pinkroom/marketsight/data/mapper/RemoteMapper.kt new file mode 100644 index 0000000..37a3fe0 --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/data/mapper/RemoteMapper.kt @@ -0,0 +1,48 @@ +package dev.pinkroom.marketsight.data.mapper + +import dev.pinkroom.marketsight.data.remote.model.dto.ErrorMessageDto +import dev.pinkroom.marketsight.data.remote.model.dto.NewsMessageDto +import dev.pinkroom.marketsight.data.remote.model.dto.SubscriptionMessageDto +import dev.pinkroom.marketsight.domain.model.ErrorMessage +import dev.pinkroom.marketsight.domain.model.NewsInfo +import dev.pinkroom.marketsight.domain.model.SubscriptionMessage +import java.time.LocalDateTime +import java.time.ZoneId +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import java.util.Locale + +fun NewsMessageDto.toNewsInfo(): NewsInfo { + return NewsInfo( + author = this.author, + createdAt = this.createdAt.toLocalDateTime(), + headline = this.headline, + id = this.id, + source = this.source, + summary = this.summary, + symbols = this.symbols, + updatedAt = this.updatedAt.toLocalDateTime(), + url = this.url, + ) +} + +fun ErrorMessageDto.toErrorMessage(): ErrorMessage { + return ErrorMessage( + code = this.code, + msg = this.msg, + ) +} + +fun SubscriptionMessageDto.toSubscriptionMessage(): SubscriptionMessage { + return SubscriptionMessage( + news = this.news, + quotes = this.quotes, + trades = this.trades, + ) +} + +fun String.toLocalDateTime(): LocalDateTime { + val parser = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.getDefault()) + val date = LocalDateTime.parse(this, parser) + return date.atZone(ZoneOffset.UTC).withZoneSameInstant(ZoneId.systemDefault()).toLocalDateTime() +} \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/data/remote/AlpacaService.kt b/app/src/main/java/dev/pinkroom/marketsight/data/remote/AlpacaService.kt index ad8659a..082e7a6 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/data/remote/AlpacaService.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/data/remote/AlpacaService.kt @@ -3,17 +3,16 @@ package dev.pinkroom.marketsight.data.remote import com.tinder.scarlet.WebSocket import com.tinder.scarlet.ws.Receive import com.tinder.scarlet.ws.Send -import dev.pinkroom.marketsight.data.remote.model.dto.ResponseAlpacaServiceDto import dev.pinkroom.marketsight.data.remote.model.request.MessageAlpacaService import kotlinx.coroutines.flow.Flow interface AlpacaService { + @Receive + fun observeOnConnectionEvent(): Flow + @Send fun sendSubscribe(message: MessageAlpacaService) @Receive - fun observeResponse(): Flow - - @Receive - fun observeOnConnectionEvent(): Flow + fun observeResponse(): Flow> } \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/MessageErrorDto.kt b/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/ErrorMessageDto.kt similarity index 77% rename from app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/MessageErrorDto.kt rename to app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/ErrorMessageDto.kt index aa322c5..8ab15fc 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/MessageErrorDto.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/ErrorMessageDto.kt @@ -1,6 +1,6 @@ package dev.pinkroom.marketsight.data.remote.model.dto -data class MessageErrorDto( +data class ErrorMessageDto( val code: Int, val msg: String, ) diff --git a/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/NewsMessageDto.kt b/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/NewsMessageDto.kt index 10a515f..4a691be 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/NewsMessageDto.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/NewsMessageDto.kt @@ -2,8 +2,9 @@ package dev.pinkroom.marketsight.data.remote.model.dto import com.google.gson.annotations.SerializedName + data class NewsMessageDto( - val id: Int, + val id: Long, val headline: String, val summary: String, val author: String, @@ -11,6 +12,6 @@ data class NewsMessageDto( @SerializedName("updated_at") val updatedAt: String, val url: String, val content: String, - val symbols: List, + val symbols: List = emptyList(), val source: String, ) diff --git a/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/ResponseAlpacaServiceDto.kt b/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/ResponseAlpacaServiceDto.kt deleted file mode 100644 index d6a3ab3..0000000 --- a/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/ResponseAlpacaServiceDto.kt +++ /dev/null @@ -1,5 +0,0 @@ -package dev.pinkroom.marketsight.data.remote.model.dto - -data class ResponseAlpacaServiceDto( - val data: List = emptyList(), -) \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/SubscriptionMessageDto.kt b/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/SubscriptionMessageDto.kt new file mode 100644 index 0000000..34a55b6 --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/SubscriptionMessageDto.kt @@ -0,0 +1,7 @@ +package dev.pinkroom.marketsight.data.remote.model.dto + +data class SubscriptionMessageDto( + val news: List? = null, + val quotes: List? = null, + val trades: List? = null, +) \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/TypeMessageDto.kt b/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/TypeMessageDto.kt new file mode 100644 index 0000000..4bf03de --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/TypeMessageDto.kt @@ -0,0 +1,7 @@ +package dev.pinkroom.marketsight.data.remote.model.dto + +import com.google.gson.annotations.SerializedName + +data class TypeMessageDto( + @SerializedName("T") val value: String, +) diff --git a/app/src/main/java/dev/pinkroom/marketsight/data/repository/NewsRepositoryImp.kt b/app/src/main/java/dev/pinkroom/marketsight/data/repository/NewsRepositoryImp.kt new file mode 100644 index 0000000..08819b4 --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/data/repository/NewsRepositoryImp.kt @@ -0,0 +1,44 @@ +package dev.pinkroom.marketsight.data.repository + +import android.util.Log +import com.tinder.scarlet.WebSocket +import dev.pinkroom.marketsight.common.ActionAlpaca +import dev.pinkroom.marketsight.common.DispatcherProvider +import dev.pinkroom.marketsight.common.Resource +import dev.pinkroom.marketsight.data.data_source.AlpacaRemoteDataSource +import dev.pinkroom.marketsight.data.remote.model.request.MessageAlpacaService +import dev.pinkroom.marketsight.domain.model.NewsInfo +import dev.pinkroom.marketsight.domain.model.SubscriptionMessage +import dev.pinkroom.marketsight.domain.repository.NewsRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import javax.inject.Inject + +class NewsRepositoryImp @Inject constructor( + private val remoteDataSource: AlpacaRemoteDataSource, + private val dispatchers: DispatcherProvider, +): NewsRepository { + override fun subscribeNews( + symbols: List, + ): Flow> = flow { + remoteDataSource.subscribeNews(symbols = symbols).collect{ + emit(it) + } + }.flowOn(dispatchers.IO) + + override fun getRealTimeNews(): Flow>> = flow { + remoteDataSource.getRealTimeNews().collect{ + emit(it) + } + }.flowOn(dispatchers.IO) + + override fun changeFilterNews( + symbolsToSubscribe: List?, + symbolsToUnsubscribe: List? + ): Flow> = flow> { + remoteDataSource.sendMessageToAlpacaService(message = MessageAlpacaService(action = ActionAlpaca.Subscribe.action, news = listOf("*"))).collect{ + + } + }.flowOn(dispatchers.IO) +} \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/di/AppModule.kt b/app/src/main/java/dev/pinkroom/marketsight/di/AppModule.kt index 3f63942..d5f9874 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/di/AppModule.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/di/AppModule.kt @@ -1,15 +1,28 @@ package dev.pinkroom.marketsight.di +import android.content.Context +import com.google.gson.Gson +import com.tinder.scarlet.Lifecycle import com.tinder.scarlet.Scarlet +import com.tinder.scarlet.lifecycle.android.AndroidLifecycle import com.tinder.scarlet.messageadapter.gson.GsonMessageAdapter import com.tinder.scarlet.websocket.okhttp.newWebSocketFactory import dagger.Module import dagger.Provides import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import dev.pinkroom.marketsight.BuildConfig +import dev.pinkroom.marketsight.MarketSightApp +import dev.pinkroom.marketsight.common.DefaultDispatchers +import dev.pinkroom.marketsight.common.DispatcherProvider import dev.pinkroom.marketsight.common.FlowStreamAdapterFactory +import dev.pinkroom.marketsight.common.addAuthenticationInterceptor +import dev.pinkroom.marketsight.common.addLoggingInterceptor +import dev.pinkroom.marketsight.data.data_source.AlpacaRemoteDataSource import dev.pinkroom.marketsight.data.remote.AlpacaService +import dev.pinkroom.marketsight.data.repository.NewsRepositoryImp +import dev.pinkroom.marketsight.domain.repository.NewsRepository import okhttp3.OkHttpClient import java.util.concurrent.TimeUnit import javax.inject.Singleton @@ -26,28 +39,42 @@ object AppModule { .connectTimeout(API_TIMEOUT, TimeUnit.SECONDS) .readTimeout(API_TIMEOUT, TimeUnit.SECONDS) .writeTimeout(API_TIMEOUT, TimeUnit.SECONDS) - .addInterceptor { chain -> - val original = chain.request() - - // Request customization: add request headers - val requestBuilder = original.newBuilder() - .addHeader("APCA-API-KEY-ID", BuildConfig.ALPACA_API_ID) - .addHeader("APCA-API-SECRET-KEY", BuildConfig.ALPACA_API_SECRET) - - val request = requestBuilder.build() - chain.proceed(request) - } + .addAuthenticationInterceptor() + .addLoggingInterceptor() .build() @Provides @Singleton - fun provideAlpacaNewsService(okHttpClient: OkHttpClient): AlpacaService { + fun provideLifeCycle(@ApplicationContext context: Context): Lifecycle = AndroidLifecycle.ofApplicationForeground( + context as MarketSightApp + ) + + @Provides + @Singleton + fun provideAlpacaNewsService(okHttpClient: OkHttpClient, lifecycle: Lifecycle): AlpacaService { val scarlet = Scarlet.Builder() .webSocketFactory(okHttpClient.newWebSocketFactory(ALPACA_STREAM_URL_NEWS)) .addMessageAdapterFactory(GsonMessageAdapter.Factory()) .addStreamAdapterFactory(FlowStreamAdapterFactory()) + //.lifecycle(lifecycle) .build() return scarlet.create(AlpacaService::class.java) } + + @Provides + @Singleton + fun provideGson(): Gson = Gson() + + @Provides + @Singleton + fun provideDispatchers(): DispatcherProvider{ + return DefaultDispatchers() + } + + @Provides + @Singleton + fun provideNewsRepository(alpacaRemoteDataSource: AlpacaRemoteDataSource, dispatcherProvider: DispatcherProvider): NewsRepository { + return NewsRepositoryImp(remoteDataSource = alpacaRemoteDataSource, dispatchers = dispatcherProvider) + } } \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/domain/model/ErrorMessage.kt b/app/src/main/java/dev/pinkroom/marketsight/domain/model/ErrorMessage.kt new file mode 100644 index 0000000..fd49ed2 --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/domain/model/ErrorMessage.kt @@ -0,0 +1,6 @@ +package dev.pinkroom.marketsight.domain.model + +data class ErrorMessage( + val code: Int, + val msg: String, +) diff --git a/app/src/main/java/dev/pinkroom/marketsight/domain/model/NewsInfo.kt b/app/src/main/java/dev/pinkroom/marketsight/domain/model/NewsInfo.kt new file mode 100644 index 0000000..cdc979c --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/domain/model/NewsInfo.kt @@ -0,0 +1,15 @@ +package dev.pinkroom.marketsight.domain.model + +import java.time.LocalDateTime + +data class NewsInfo( + val id: Long, + val headline: String, + val summary: String, + val author: String, + val createdAt: LocalDateTime, + val updatedAt: LocalDateTime, + val url: String, + val symbols: List, + val source: String, +) diff --git a/app/src/main/java/dev/pinkroom/marketsight/domain/model/SubscriptionMessage.kt b/app/src/main/java/dev/pinkroom/marketsight/domain/model/SubscriptionMessage.kt new file mode 100644 index 0000000..9922f47 --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/domain/model/SubscriptionMessage.kt @@ -0,0 +1,7 @@ +package dev.pinkroom.marketsight.domain.model + +data class SubscriptionMessage( + val news: List? = null, + val quotes: List? = null, + val trades: List? = null, +) diff --git a/app/src/main/java/dev/pinkroom/marketsight/domain/repository/NewsRepository.kt b/app/src/main/java/dev/pinkroom/marketsight/domain/repository/NewsRepository.kt index f7ff5cb..8d9eaf6 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/domain/repository/NewsRepository.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/domain/repository/NewsRepository.kt @@ -1,4 +1,18 @@ package dev.pinkroom.marketsight.domain.repository +import com.tinder.scarlet.WebSocket +import dev.pinkroom.marketsight.common.Resource +import dev.pinkroom.marketsight.domain.model.NewsInfo +import dev.pinkroom.marketsight.domain.model.SubscriptionMessage +import kotlinx.coroutines.flow.Flow + interface NewsRepository { + fun subscribeNews( + symbols: List = listOf("*"), + ): Flow> + fun getRealTimeNews(): Flow>> + fun changeFilterNews( + symbolsToSubscribe: List? = null, + symbolsToUnsubscribe: List? = null, + ): Flow> } \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/MainActivity.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/MainActivity.kt index 5dc3de5..16fefa9 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/MainActivity.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/MainActivity.kt @@ -1,6 +1,5 @@ package dev.pinkroom.marketsight.ui -import android.annotation.SuppressLint import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -11,13 +10,14 @@ import androidx.compose.material3.Surface import androidx.compose.ui.Modifier import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.navigation.compose.rememberNavController +import dagger.hilt.android.AndroidEntryPoint import dev.pinkroom.marketsight.ui.core.navigation.NavigationAppHost import dev.pinkroom.marketsight.ui.core.navigation.NavigationBottomBar import dev.pinkroom.marketsight.ui.core.navigation.Route import dev.pinkroom.marketsight.ui.core.theme.MarketSightTheme +@AndroidEntryPoint class MainActivity : ComponentActivity() { - @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") override fun onCreate(savedInstanceState: Bundle?) { installSplashScreen() super.onCreate(savedInstanceState) diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/NavigationAppHost.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/NavigationAppHost.kt index 2682fcf..bc34185 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/NavigationAppHost.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/NavigationAppHost.kt @@ -2,6 +2,7 @@ package dev.pinkroom.marketsight.ui.core.navigation import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavHostController import androidx.navigation.NavType import androidx.navigation.compose.NavHost @@ -10,6 +11,7 @@ import androidx.navigation.navArgument import dev.pinkroom.marketsight.ui.core.navigation.Args.SYMBOL_ID import dev.pinkroom.marketsight.ui.detail_screen.DetailScreen import dev.pinkroom.marketsight.ui.home_screen.HomeScreen +import dev.pinkroom.marketsight.ui.home_screen.HomeViewModel import dev.pinkroom.marketsight.ui.news_screen.NewsScreen @Composable @@ -26,6 +28,7 @@ fun NavigationAppHost( composable( route = Route.HomeScreen.route, ) { + val viewModel = hiltViewModel() HomeScreen() } composable( diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/home_screen/HomeViewModel.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/home_screen/HomeViewModel.kt new file mode 100644 index 0000000..fe5637d --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/home_screen/HomeViewModel.kt @@ -0,0 +1,33 @@ +package dev.pinkroom.marketsight.ui.home_screen + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import dev.pinkroom.marketsight.domain.repository.NewsRepository +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class HomeViewModel @Inject constructor( + newsRepository: NewsRepository +): ViewModel(){ + init { + viewModelScope.launch { + newsRepository.subscribeNews().collect{ + + } + } + viewModelScope.launch { + newsRepository.getRealTimeNews().collect{ + + } + } + viewModelScope.launch { + delay(10000) + newsRepository.changeFilterNews().collect{ + + } + } + } +} \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 173ec13..86c6d48 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,4 +3,6 @@ plugins { alias(libs.plugins.androidApplication) apply false alias(libs.plugins.jetbrainsKotlinAndroid) apply false alias(libs.plugins.hiltAndroid) apply false + alias(libs.plugins.jvm) apply false + alias(libs.plugins.jsonSerialization) apply false } \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ec3615f..70d6491 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,13 +7,15 @@ junitVersion = "1.1.5" espressoCore = "3.5.1" lifecycleRuntimeKtx = "2.7.0" activityCompose = "1.8.2" -composeBom = "2024.03.00" +composeBom = "2024.04.00" retrofitVersion = "2.11.0" okHttpVersion = "4.12.0" scarletVersion = "0.1.12" hiltVersion = "2.51.1" hiltNavigationVersion = "1.2.0" splashVersion = "1.0.1" +jsonVersion = "1.6.0" +jsonPluginVersion = "1.9.23" [libraries] @@ -33,6 +35,10 @@ androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", versi retrofit-core = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofitVersion" } retrofit-gson = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofitVersion" } +# JSON +json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "jsonVersion" } + + # OkHTTP okhttp-core = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okHttpVersion" } okhttp-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okHttpVersion" } @@ -41,12 +47,14 @@ okhttp-interceptor = { group = "com.squareup.okhttp3", name = "logging-intercept scarlet-core = { group = "com.tinder.scarlet", name = "scarlet", version.ref = "scarletVersion" } scarlet-stream = { group = "com.tinder.scarlet", name = "stream-adapter-coroutines", version.ref = "scarletVersion" } scarlet-gson = { group = "com.tinder.scarlet", name = "message-adapter-gson", version.ref = "scarletVersion" } +scarlet-moshi = { group = "com.tinder.scarlet", name="message-adapter-moshi", version.ref = "scarletVersion" } scarlet-okhttp = { group = "com.tinder.scarlet", name = "websocket-okhttp", version.ref = "scarletVersion" } +scarlet-lifecycle = { group = "com.tinder.scarlet", name = "lifecycle-android", version.ref = "scarletVersion" } # DAGGER HILT hilt-android = { group = "com.google.dagger", name = "hilt-android", version.ref = "hiltVersion" } hilt-navigation = { group = "androidx.hilt", name = "hilt-navigation-compose", version.ref = "hiltNavigationVersion" } -hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hiltVersion" } +hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hiltVersion" } # SPLASH splash = { group = "androidx.core", name = "core-splashscreen", version.ref= "splashVersion" } @@ -68,4 +76,6 @@ androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-man androidApplication = { id = "com.android.application", version.ref = "agp" } jetbrainsKotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } hiltAndroid = { id = "com.google.dagger.hilt.android", version.ref = "hiltVersion" } +jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin"} +jsonSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin"} From 739ee0136f4c3bded2b78c7b260fcaf6c6ddd78d Mon Sep 17 00:00:00 2001 From: LucasPrioste Date: Thu, 11 Apr 2024 12:55:09 +0100 Subject: [PATCH 08/24] - refactor alpaca news ws service - use case to get News --- .../pinkroom/marketsight/common/Resource.kt | 2 +- .../data_source/AlpacaRemoteDataSource.kt | 15 +++---- .../marketsight/data/remote/AlpacaService.kt | 2 +- .../data/repository/NewsRepositoryImp.kt | 31 +++++++------ .../domain/repository/NewsRepository.kt | 12 +++--- .../domain/use_case/news/ChangeFilterNews.kt | 43 +++++++++++++++++++ .../domain/use_case/news/GetRealTimeNews.kt | 16 +++++++ .../domain/use_case/news/SubscribeNews.kt | 16 +++++++ .../ui/home_screen/HomeViewModel.kt | 22 ---------- 9 files changed, 104 insertions(+), 55 deletions(-) create mode 100644 app/src/main/java/dev/pinkroom/marketsight/domain/use_case/news/ChangeFilterNews.kt create mode 100644 app/src/main/java/dev/pinkroom/marketsight/domain/use_case/news/GetRealTimeNews.kt create mode 100644 app/src/main/java/dev/pinkroom/marketsight/domain/use_case/news/SubscribeNews.kt diff --git a/app/src/main/java/dev/pinkroom/marketsight/common/Resource.kt b/app/src/main/java/dev/pinkroom/marketsight/common/Resource.kt index c82f995..4317137 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/common/Resource.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/common/Resource.kt @@ -3,7 +3,7 @@ package dev.pinkroom.marketsight.common import dev.pinkroom.marketsight.domain.model.ErrorMessage sealed class Resource{ - data class Success(val data: T?): Resource() + data class Success(val data: T): Resource() data class Error( val message: String? = null, val errorInfo: ErrorMessage? = null, diff --git a/app/src/main/java/dev/pinkroom/marketsight/data/data_source/AlpacaRemoteDataSource.kt b/app/src/main/java/dev/pinkroom/marketsight/data/data_source/AlpacaRemoteDataSource.kt index 494501c..49ac30b 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/data/data_source/AlpacaRemoteDataSource.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/data/data_source/AlpacaRemoteDataSource.kt @@ -16,9 +16,7 @@ import dev.pinkroom.marketsight.data.mapper.toSubscriptionMessage import dev.pinkroom.marketsight.data.remote.AlpacaService import dev.pinkroom.marketsight.data.remote.model.dto.NewsMessageDto import dev.pinkroom.marketsight.data.remote.model.request.MessageAlpacaService -import dev.pinkroom.marketsight.domain.model.NewsInfo import dev.pinkroom.marketsight.domain.model.SubscriptionMessage -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.take @@ -29,11 +27,11 @@ class AlpacaRemoteDataSource @Inject constructor( private val alpacaService: AlpacaService, private val dispatchers: DispatcherProvider, ) { - fun subscribeNews(symbols: List = listOf("*")): Flow> = flow> { + fun subscribeNews(symbols: List = listOf("*")) = flow> { alpacaService.observeOnConnectionEvent().collect{ when(it){ is WebSocket.Event.OnConnectionOpened<*> -> { - alpacaService.sendSubscribe(message = MessageAlpacaService(action = ActionAlpaca.Subscribe.action, news = symbols)) + alpacaService.sendMessage(message = MessageAlpacaService(action = ActionAlpaca.Subscribe.action, news = symbols)) } is WebSocket.Event.OnMessageReceived -> { Log.d(TAG,"Received: ${it.message}") @@ -53,21 +51,20 @@ class AlpacaRemoteDataSource @Inject constructor( } }.flowOn(dispatchers.IO) - fun getRealTimeNews(): Flow>> = flow { + fun getRealTimeNews() = flow { alpacaService.observeResponse().collect{ data -> - Log.d("TESTE","AQUI SEMPRE") val listNews = mutableListOf() data.forEach { gson.toObject(value = it, helperIdentifier = HelperIdentifierMessagesAlpacaService.News)?.let { news -> listNews.add(news) } } - emit(Resource.Success(listNews.map { it.toNewsInfo() })) + if (listNews.isNotEmpty()) emit(listNews.map { it.toNewsInfo() }) } }.flowOn(dispatchers.IO) - fun sendMessageToAlpacaService(message: MessageAlpacaService): Flow> = flow> { - alpacaService.sendSubscribe(message = message) + fun sendMessageToAlpacaService(message: MessageAlpacaService) = flow> { + alpacaService.sendMessage(message = message) alpacaService.observeResponse().collect { data -> data.forEach { gson.toObject(value = it, helperIdentifier = HelperIdentifierMessagesAlpacaService.Subscription)?.let { sub -> diff --git a/app/src/main/java/dev/pinkroom/marketsight/data/remote/AlpacaService.kt b/app/src/main/java/dev/pinkroom/marketsight/data/remote/AlpacaService.kt index 082e7a6..bf44654 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/data/remote/AlpacaService.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/data/remote/AlpacaService.kt @@ -11,7 +11,7 @@ interface AlpacaService { fun observeOnConnectionEvent(): Flow @Send - fun sendSubscribe(message: MessageAlpacaService) + fun sendMessage(message: MessageAlpacaService) @Receive fun observeResponse(): Flow> diff --git a/app/src/main/java/dev/pinkroom/marketsight/data/repository/NewsRepositoryImp.kt b/app/src/main/java/dev/pinkroom/marketsight/data/repository/NewsRepositoryImp.kt index 08819b4..d35287c 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/data/repository/NewsRepositoryImp.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/data/repository/NewsRepositoryImp.kt @@ -1,44 +1,43 @@ package dev.pinkroom.marketsight.data.repository -import android.util.Log -import com.tinder.scarlet.WebSocket import dev.pinkroom.marketsight.common.ActionAlpaca import dev.pinkroom.marketsight.common.DispatcherProvider import dev.pinkroom.marketsight.common.Resource import dev.pinkroom.marketsight.data.data_source.AlpacaRemoteDataSource import dev.pinkroom.marketsight.data.remote.model.request.MessageAlpacaService -import dev.pinkroom.marketsight.domain.model.NewsInfo -import dev.pinkroom.marketsight.domain.model.SubscriptionMessage import dev.pinkroom.marketsight.domain.repository.NewsRepository -import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.single import javax.inject.Inject class NewsRepositoryImp @Inject constructor( private val remoteDataSource: AlpacaRemoteDataSource, private val dispatchers: DispatcherProvider, ): NewsRepository { - override fun subscribeNews( - symbols: List, - ): Flow> = flow { + override fun subscribeNews(symbols: List) = flow { remoteDataSource.subscribeNews(symbols = symbols).collect{ emit(it) } }.flowOn(dispatchers.IO) - override fun getRealTimeNews(): Flow>> = flow { + override fun getRealTimeNews() = flow { remoteDataSource.getRealTimeNews().collect{ emit(it) } }.flowOn(dispatchers.IO) - override fun changeFilterNews( - symbolsToSubscribe: List?, - symbolsToUnsubscribe: List? - ): Flow> = flow> { - remoteDataSource.sendMessageToAlpacaService(message = MessageAlpacaService(action = ActionAlpaca.Subscribe.action, news = listOf("*"))).collect{ - + override suspend fun changeFilterNews( + symbols: List, + actionAlpaca: ActionAlpaca, + ): Resource> = flow { + val message = MessageAlpacaService(action = actionAlpaca.action, news = symbols) + remoteDataSource.sendMessageToAlpacaService(message = message).collect{ response -> + when(response){ + is Resource.Error -> emit(Resource.Error(data = symbols)) + is Resource.Success -> emit(Resource.Success(data = response.data.news ?: symbols)) + else -> Unit + } } - }.flowOn(dispatchers.IO) + }.flowOn(dispatchers.IO).single() } \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/domain/repository/NewsRepository.kt b/app/src/main/java/dev/pinkroom/marketsight/domain/repository/NewsRepository.kt index 8d9eaf6..bbb50c1 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/domain/repository/NewsRepository.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/domain/repository/NewsRepository.kt @@ -1,18 +1,18 @@ package dev.pinkroom.marketsight.domain.repository import com.tinder.scarlet.WebSocket +import dev.pinkroom.marketsight.common.ActionAlpaca import dev.pinkroom.marketsight.common.Resource import dev.pinkroom.marketsight.domain.model.NewsInfo -import dev.pinkroom.marketsight.domain.model.SubscriptionMessage import kotlinx.coroutines.flow.Flow interface NewsRepository { fun subscribeNews( symbols: List = listOf("*"), ): Flow> - fun getRealTimeNews(): Flow>> - fun changeFilterNews( - symbolsToSubscribe: List? = null, - symbolsToUnsubscribe: List? = null, - ): Flow> + fun getRealTimeNews(): Flow> + suspend fun changeFilterNews( + symbols: List, + actionAlpaca: ActionAlpaca, + ): Resource> } \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/domain/use_case/news/ChangeFilterNews.kt b/app/src/main/java/dev/pinkroom/marketsight/domain/use_case/news/ChangeFilterNews.kt new file mode 100644 index 0000000..d34b755 --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/domain/use_case/news/ChangeFilterNews.kt @@ -0,0 +1,43 @@ +package dev.pinkroom.marketsight.domain.use_case.news + +import dev.pinkroom.marketsight.common.ActionAlpaca +import dev.pinkroom.marketsight.common.DispatcherProvider +import dev.pinkroom.marketsight.common.Resource +import dev.pinkroom.marketsight.domain.repository.NewsRepository +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import javax.inject.Inject + +class ChangeFilterNews @Inject constructor( + private val newsRepository: NewsRepository, + private val dispatchers: DispatcherProvider, +){ + operator fun invoke( + subscribeSymbols: List? = null, + unsubscribeSymbols: List? = null, + ) = flow>> { + val symbolsToRevert = mutableListOf() + unsubscribeSymbols?.let { symbols -> + val response = newsRepository.changeFilterNews(symbols = symbols, actionAlpaca = ActionAlpaca.Unsubscribe) + when(response){ + is Resource.Error -> { + symbolsToRevert.addAll(unsubscribeSymbols) + } + else -> Unit + } + } + + subscribeSymbols?.let { symbols -> + val response = newsRepository.changeFilterNews(symbols = symbols, actionAlpaca = ActionAlpaca.Subscribe) + when(response){ + is Resource.Success -> emit(Resource.Success(data = response.data)) + is Resource.Error -> { + symbolsToRevert.addAll(subscribeSymbols) + } + else -> Unit + } + } + + if (symbolsToRevert.isNotEmpty()) emit(Resource.Error(data = symbolsToRevert)) + }.flowOn(dispatchers.IO) +} \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/domain/use_case/news/GetRealTimeNews.kt b/app/src/main/java/dev/pinkroom/marketsight/domain/use_case/news/GetRealTimeNews.kt new file mode 100644 index 0000000..2229ddb --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/domain/use_case/news/GetRealTimeNews.kt @@ -0,0 +1,16 @@ +package dev.pinkroom.marketsight.domain.use_case.news + +import dev.pinkroom.marketsight.common.DispatcherProvider +import dev.pinkroom.marketsight.domain.repository.NewsRepository +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import javax.inject.Inject + +class GetRealTimeNews @Inject constructor( + private val newsRepository: NewsRepository, + private val dispatchers: DispatcherProvider, +){ + operator fun invoke() = flow { + emit(newsRepository.getRealTimeNews()) + }.flowOn(dispatchers.IO) +} \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/domain/use_case/news/SubscribeNews.kt b/app/src/main/java/dev/pinkroom/marketsight/domain/use_case/news/SubscribeNews.kt new file mode 100644 index 0000000..79ca029 --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/domain/use_case/news/SubscribeNews.kt @@ -0,0 +1,16 @@ +package dev.pinkroom.marketsight.domain.use_case.news + +import dev.pinkroom.marketsight.common.DispatcherProvider +import dev.pinkroom.marketsight.domain.repository.NewsRepository +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import javax.inject.Inject + +class SubscribeNews @Inject constructor( + private val newsRepository: NewsRepository, + private val dispatchers: DispatcherProvider, +){ + operator fun invoke() = flow { + emit(newsRepository.subscribeNews()) + }.flowOn(dispatchers.IO) +} \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/home_screen/HomeViewModel.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/home_screen/HomeViewModel.kt index fe5637d..54bdcf1 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/home_screen/HomeViewModel.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/home_screen/HomeViewModel.kt @@ -1,33 +1,11 @@ package dev.pinkroom.marketsight.ui.home_screen import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel -import dev.pinkroom.marketsight.domain.repository.NewsRepository -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class HomeViewModel @Inject constructor( - newsRepository: NewsRepository ): ViewModel(){ - init { - viewModelScope.launch { - newsRepository.subscribeNews().collect{ - } - } - viewModelScope.launch { - newsRepository.getRealTimeNews().collect{ - - } - } - viewModelScope.launch { - delay(10000) - newsRepository.changeFilterNews().collect{ - - } - } - } } \ No newline at end of file From 201b4b2126144c0a3257eb0ac151d79beabd3eaf Mon Sep 17 00:00:00 2001 From: LucasPrioste Date: Thu, 11 Apr 2024 16:21:04 +0100 Subject: [PATCH 09/24] - alpaca news API --- app/build.gradle.kts | 4 +- .../pinkroom/marketsight/common/GsonUtils.kt | 4 +- .../HelperIdentifierMessagesAlpacaService.kt | 6 +- .../pinkroom/marketsight/common/Resource.kt | 2 +- .../dev/pinkroom/marketsight/common/Utils.kt | 5 ++ .../data_source/AlpacaRemoteDataSource.kt | 43 ++++++++++++--- .../marketsight/data/mapper/RemoteMapper.kt | 55 +++++++++++++++++-- .../marketsight/data/remote/AlpacaApi.kt | 4 -- .../marketsight/data/remote/AlpacaNewsApi.kt | 15 +++++ ...{AlpacaService.kt => AlpacaNewsService.kt} | 2 +- .../data/remote/model/dto/ErrorMessageDto.kt | 6 -- .../dto/alpaca_news_api/ImagesNewsDto.kt | 6 ++ .../model/dto/alpaca_news_api/NewsDto.kt | 16 ++++++ .../dto/alpaca_news_api/NewsResponseDto.kt | 8 +++ .../alpaca_news_service/ErrorMessageDto.kt | 6 ++ .../NewsMessageDto.kt | 3 +- .../SubscriptionMessageDto.kt | 2 +- .../TypeMessageDto.kt | 2 +- .../data/repository/NewsRepositoryImp.kt | 16 ++++++ .../dev/pinkroom/marketsight/di/AppModule.kt | 21 ++++++- .../domain/model/{ => common}/ErrorMessage.kt | 2 +- .../model/{ => common}/SubscriptionMessage.kt | 2 +- .../domain/model/news/ImageSize.kt | 7 +++ .../domain/model/news/ImagesNews.kt | 6 ++ .../domain/model/{ => news}/NewsInfo.kt | 3 +- .../domain/model/news/NewsResponse.kt | 6 ++ .../domain/repository/NewsRepository.kt | 11 +++- .../domain/use_case/news/GetNews.kt | 12 ++++ 28 files changed, 231 insertions(+), 44 deletions(-) delete mode 100644 app/src/main/java/dev/pinkroom/marketsight/data/remote/AlpacaApi.kt create mode 100644 app/src/main/java/dev/pinkroom/marketsight/data/remote/AlpacaNewsApi.kt rename app/src/main/java/dev/pinkroom/marketsight/data/remote/{AlpacaService.kt => AlpacaNewsService.kt} (93%) delete mode 100644 app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/ErrorMessageDto.kt create mode 100644 app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/alpaca_news_api/ImagesNewsDto.kt create mode 100644 app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/alpaca_news_api/NewsDto.kt create mode 100644 app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/alpaca_news_api/NewsResponseDto.kt create mode 100644 app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/alpaca_news_service/ErrorMessageDto.kt rename app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/{ => alpaca_news_service}/NewsMessageDto.kt (82%) rename app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/{ => alpaca_news_service}/SubscriptionMessageDto.kt (66%) rename app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/{ => alpaca_news_service}/TypeMessageDto.kt (62%) rename app/src/main/java/dev/pinkroom/marketsight/domain/model/{ => common}/ErrorMessage.kt (56%) rename app/src/main/java/dev/pinkroom/marketsight/domain/model/{ => common}/SubscriptionMessage.kt (73%) create mode 100644 app/src/main/java/dev/pinkroom/marketsight/domain/model/news/ImageSize.kt create mode 100644 app/src/main/java/dev/pinkroom/marketsight/domain/model/news/ImagesNews.kt rename app/src/main/java/dev/pinkroom/marketsight/domain/model/{ => news}/NewsInfo.kt (75%) create mode 100644 app/src/main/java/dev/pinkroom/marketsight/domain/model/news/NewsResponse.kt create mode 100644 app/src/main/java/dev/pinkroom/marketsight/domain/use_case/news/GetNews.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 381da7e..c8fcf6f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -29,10 +29,12 @@ android { properties.load(keystoreFile.inputStream()) val alpacaStreamUrl = properties.getProperty("ALPACA_STREAM_URL") ?: "" + val alpacaDataUrl = properties.getProperty("ALPACA_DATA_URL") ?: "" val alpacaApiId = properties.getProperty("ALPACA_API_ID") ?: "" val alpacaApiSecret = properties.getProperty("ALPACA_API_SECRET") ?: "" buildConfigField(type = "String", name = "ALPACA_STREAM_URL", value = alpacaStreamUrl) + buildConfigField(type = "String", name = "ALPACA_DATA_URL", value = alpacaDataUrl) buildConfigField(type = "String", name = "ALPACA_API_ID", value = alpacaApiId) buildConfigField(type = "String", name = "ALPACA_API_SECRET", value = alpacaApiSecret) } @@ -70,7 +72,7 @@ android { dependencies { // CORE - coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.4") + coreLibraryDesugaring(libs.androidx.desugar) implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.ui) diff --git a/app/src/main/java/dev/pinkroom/marketsight/common/GsonUtils.kt b/app/src/main/java/dev/pinkroom/marketsight/common/GsonUtils.kt index f31803f..c504068 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/common/GsonUtils.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/common/GsonUtils.kt @@ -1,8 +1,8 @@ package dev.pinkroom.marketsight.common import com.google.gson.Gson -import dev.pinkroom.marketsight.data.remote.model.dto.ErrorMessageDto -import dev.pinkroom.marketsight.data.remote.model.dto.TypeMessageDto +import dev.pinkroom.marketsight.data.remote.model.dto.alpaca_news_service.ErrorMessageDto +import dev.pinkroom.marketsight.data.remote.model.dto.alpaca_news_service.TypeMessageDto fun Gson.toJsonValue(value: Any?, classOfT: Class): T = fromJson(toJson(value), classOfT) diff --git a/app/src/main/java/dev/pinkroom/marketsight/common/HelperIdentifierMessagesAlpacaService.kt b/app/src/main/java/dev/pinkroom/marketsight/common/HelperIdentifierMessagesAlpacaService.kt index 6205a35..50099b0 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/common/HelperIdentifierMessagesAlpacaService.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/common/HelperIdentifierMessagesAlpacaService.kt @@ -1,8 +1,8 @@ package dev.pinkroom.marketsight.common -import dev.pinkroom.marketsight.data.remote.model.dto.ErrorMessageDto -import dev.pinkroom.marketsight.data.remote.model.dto.NewsMessageDto -import dev.pinkroom.marketsight.data.remote.model.dto.SubscriptionMessageDto +import dev.pinkroom.marketsight.data.remote.model.dto.alpaca_news_service.ErrorMessageDto +import dev.pinkroom.marketsight.data.remote.model.dto.alpaca_news_service.NewsMessageDto +import dev.pinkroom.marketsight.data.remote.model.dto.alpaca_news_service.SubscriptionMessageDto sealed class HelperIdentifierMessagesAlpacaService( val identifier: String, diff --git a/app/src/main/java/dev/pinkroom/marketsight/common/Resource.kt b/app/src/main/java/dev/pinkroom/marketsight/common/Resource.kt index 4317137..610ce0c 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/common/Resource.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/common/Resource.kt @@ -1,6 +1,6 @@ package dev.pinkroom.marketsight.common -import dev.pinkroom.marketsight.domain.model.ErrorMessage +import dev.pinkroom.marketsight.domain.model.common.ErrorMessage sealed class Resource{ data class Success(val data: T): Resource() diff --git a/app/src/main/java/dev/pinkroom/marketsight/common/Utils.kt b/app/src/main/java/dev/pinkroom/marketsight/common/Utils.kt index 5c285c3..e93c9db 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/common/Utils.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/common/Utils.kt @@ -29,4 +29,9 @@ fun OkHttpClient.Builder.addLoggingInterceptor(): OkHttpClient.Builder { sealed class ActionAlpaca(val action: String) { data object Subscribe: ActionAlpaca(action = "subscribe") data object Unsubscribe: ActionAlpaca(action = "unsubscribe") +} + +sealed class SortType(val type: String){ + data object DESC: SortType(type = "desc") + data object ASC: SortType(type = "asc") } \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/data/data_source/AlpacaRemoteDataSource.kt b/app/src/main/java/dev/pinkroom/marketsight/data/data_source/AlpacaRemoteDataSource.kt index 49ac30b..1955882 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/data/data_source/AlpacaRemoteDataSource.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/data/data_source/AlpacaRemoteDataSource.kt @@ -8,15 +8,19 @@ import dev.pinkroom.marketsight.common.ActionAlpaca import dev.pinkroom.marketsight.common.DispatcherProvider import dev.pinkroom.marketsight.common.HelperIdentifierMessagesAlpacaService import dev.pinkroom.marketsight.common.Resource +import dev.pinkroom.marketsight.common.SortType import dev.pinkroom.marketsight.common.toObject import dev.pinkroom.marketsight.common.verifyIfIsError import dev.pinkroom.marketsight.data.mapper.toErrorMessage import dev.pinkroom.marketsight.data.mapper.toNewsInfo +import dev.pinkroom.marketsight.data.mapper.toNewsResponse import dev.pinkroom.marketsight.data.mapper.toSubscriptionMessage -import dev.pinkroom.marketsight.data.remote.AlpacaService -import dev.pinkroom.marketsight.data.remote.model.dto.NewsMessageDto +import dev.pinkroom.marketsight.data.remote.AlpacaNewsApi +import dev.pinkroom.marketsight.data.remote.AlpacaNewsService +import dev.pinkroom.marketsight.data.remote.model.dto.alpaca_news_service.NewsMessageDto import dev.pinkroom.marketsight.data.remote.model.request.MessageAlpacaService -import dev.pinkroom.marketsight.domain.model.SubscriptionMessage +import dev.pinkroom.marketsight.domain.model.common.SubscriptionMessage +import dev.pinkroom.marketsight.domain.model.news.NewsResponse import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.take @@ -24,14 +28,15 @@ import javax.inject.Inject class AlpacaRemoteDataSource @Inject constructor( private val gson: Gson, - private val alpacaService: AlpacaService, + private val alpacaNewsService: AlpacaNewsService, + private val alpacaNewsApi: AlpacaNewsApi, private val dispatchers: DispatcherProvider, ) { fun subscribeNews(symbols: List = listOf("*")) = flow> { - alpacaService.observeOnConnectionEvent().collect{ + alpacaNewsService.observeOnConnectionEvent().collect{ when(it){ is WebSocket.Event.OnConnectionOpened<*> -> { - alpacaService.sendMessage(message = MessageAlpacaService(action = ActionAlpaca.Subscribe.action, news = symbols)) + alpacaNewsService.sendMessage(message = MessageAlpacaService(action = ActionAlpaca.Subscribe.action, news = symbols)) } is WebSocket.Event.OnMessageReceived -> { Log.d(TAG,"Received: ${it.message}") @@ -52,7 +57,7 @@ class AlpacaRemoteDataSource @Inject constructor( }.flowOn(dispatchers.IO) fun getRealTimeNews() = flow { - alpacaService.observeResponse().collect{ data -> + alpacaNewsService.observeResponse().collect{ data -> val listNews = mutableListOf() data.forEach { gson.toObject(value = it, helperIdentifier = HelperIdentifierMessagesAlpacaService.News)?.let { news -> @@ -64,8 +69,8 @@ class AlpacaRemoteDataSource @Inject constructor( }.flowOn(dispatchers.IO) fun sendMessageToAlpacaService(message: MessageAlpacaService) = flow> { - alpacaService.sendMessage(message = message) - alpacaService.observeResponse().collect { data -> + alpacaNewsService.sendMessage(message = message) + alpacaNewsService.observeResponse().collect { data -> data.forEach { gson.toObject(value = it, helperIdentifier = HelperIdentifierMessagesAlpacaService.Subscription)?.let { sub -> emit(Resource.Success(sub.toSubscriptionMessage())) @@ -76,6 +81,26 @@ class AlpacaRemoteDataSource @Inject constructor( } }.flowOn(dispatchers.IO).take(1) + suspend fun getNews( + symbols: List?, + limit: Int?, + pageToken: String?, + sort: SortType? + ): Resource{ + return try { + val response = alpacaNewsApi.getNews( + symbols = symbols?.joinToString(","), + perPage = limit, + pageToken = pageToken, + sort = sort?.type, + ) + Resource.Success(data = response.toNewsResponse()) + } catch (e: Exception){ + e.printStackTrace() + Resource.Error(message = e.message ?: "Something Went Wrong") + } + } + companion object { const val TAG = "AlpacaRemote" } diff --git a/app/src/main/java/dev/pinkroom/marketsight/data/mapper/RemoteMapper.kt b/app/src/main/java/dev/pinkroom/marketsight/data/mapper/RemoteMapper.kt index 37a3fe0..8f7552a 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/data/mapper/RemoteMapper.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/data/mapper/RemoteMapper.kt @@ -1,11 +1,17 @@ package dev.pinkroom.marketsight.data.mapper -import dev.pinkroom.marketsight.data.remote.model.dto.ErrorMessageDto -import dev.pinkroom.marketsight.data.remote.model.dto.NewsMessageDto -import dev.pinkroom.marketsight.data.remote.model.dto.SubscriptionMessageDto -import dev.pinkroom.marketsight.domain.model.ErrorMessage -import dev.pinkroom.marketsight.domain.model.NewsInfo -import dev.pinkroom.marketsight.domain.model.SubscriptionMessage +import dev.pinkroom.marketsight.data.remote.model.dto.alpaca_news_api.ImagesNewsDto +import dev.pinkroom.marketsight.data.remote.model.dto.alpaca_news_api.NewsDto +import dev.pinkroom.marketsight.data.remote.model.dto.alpaca_news_api.NewsResponseDto +import dev.pinkroom.marketsight.data.remote.model.dto.alpaca_news_service.ErrorMessageDto +import dev.pinkroom.marketsight.data.remote.model.dto.alpaca_news_service.NewsMessageDto +import dev.pinkroom.marketsight.data.remote.model.dto.alpaca_news_service.SubscriptionMessageDto +import dev.pinkroom.marketsight.domain.model.common.ErrorMessage +import dev.pinkroom.marketsight.domain.model.common.SubscriptionMessage +import dev.pinkroom.marketsight.domain.model.news.ImageSize +import dev.pinkroom.marketsight.domain.model.news.ImagesNews +import dev.pinkroom.marketsight.domain.model.news.NewsInfo +import dev.pinkroom.marketsight.domain.model.news.NewsResponse import java.time.LocalDateTime import java.time.ZoneId import java.time.ZoneOffset @@ -45,4 +51,41 @@ fun String.toLocalDateTime(): LocalDateTime { val parser = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.getDefault()) val date = LocalDateTime.parse(this, parser) return date.atZone(ZoneOffset.UTC).withZoneSameInstant(ZoneId.systemDefault()).toLocalDateTime() +} + +fun NewsResponseDto.toNewsResponse(): NewsResponse { + return NewsResponse( + news = this.news.map { it.toNewsInfo() }, + nextPageToken = this.nextPageToken, + ) +} + +fun NewsDto.toNewsInfo(): NewsInfo { + return NewsInfo( + author = this.author, + createdAt = this.createdAt.toLocalDateTime(), + headline = this.headline, + id = this.id, + source = this.source, + summary = this.summary, + symbols = this.symbols, + updatedAt = this.updatedAt.toLocalDateTime(), + url = this.url, + images = this.images.map { it.toImagesNews() } + ) +} + +fun ImagesNewsDto.toImagesNews(): ImagesNews { + return ImagesNews( + url = this.url, + size = this.size.toImageSize(), + ) +} + +fun String.toImageSize(): ImageSize { + return when(this){ + "large" -> ImageSize.Large + "small" -> ImageSize.Small + else -> ImageSize.Thumb + } } \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/data/remote/AlpacaApi.kt b/app/src/main/java/dev/pinkroom/marketsight/data/remote/AlpacaApi.kt deleted file mode 100644 index b134966..0000000 --- a/app/src/main/java/dev/pinkroom/marketsight/data/remote/AlpacaApi.kt +++ /dev/null @@ -1,4 +0,0 @@ -package dev.pinkroom.marketsight.data.remote - -interface AlpacaApi { -} \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/data/remote/AlpacaNewsApi.kt b/app/src/main/java/dev/pinkroom/marketsight/data/remote/AlpacaNewsApi.kt new file mode 100644 index 0000000..2bbf17f --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/data/remote/AlpacaNewsApi.kt @@ -0,0 +1,15 @@ +package dev.pinkroom.marketsight.data.remote + +import dev.pinkroom.marketsight.data.remote.model.dto.alpaca_news_api.NewsResponseDto +import retrofit2.http.GET +import retrofit2.http.Query + +interface AlpacaNewsApi { + @GET("news") + suspend fun getNews( + @Query("symbols") symbols: String? = null, + @Query("limit") perPage: Int? = null, + @Query("page_token") pageToken: String? = null, + @Query("sort") sort: String? = null, + ): NewsResponseDto +} \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/data/remote/AlpacaService.kt b/app/src/main/java/dev/pinkroom/marketsight/data/remote/AlpacaNewsService.kt similarity index 93% rename from app/src/main/java/dev/pinkroom/marketsight/data/remote/AlpacaService.kt rename to app/src/main/java/dev/pinkroom/marketsight/data/remote/AlpacaNewsService.kt index bf44654..543c05e 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/data/remote/AlpacaService.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/data/remote/AlpacaNewsService.kt @@ -6,7 +6,7 @@ import com.tinder.scarlet.ws.Send import dev.pinkroom.marketsight.data.remote.model.request.MessageAlpacaService import kotlinx.coroutines.flow.Flow -interface AlpacaService { +interface AlpacaNewsService { @Receive fun observeOnConnectionEvent(): Flow diff --git a/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/ErrorMessageDto.kt b/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/ErrorMessageDto.kt deleted file mode 100644 index 8ab15fc..0000000 --- a/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/ErrorMessageDto.kt +++ /dev/null @@ -1,6 +0,0 @@ -package dev.pinkroom.marketsight.data.remote.model.dto - -data class ErrorMessageDto( - val code: Int, - val msg: String, -) diff --git a/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/alpaca_news_api/ImagesNewsDto.kt b/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/alpaca_news_api/ImagesNewsDto.kt new file mode 100644 index 0000000..c9aa082 --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/alpaca_news_api/ImagesNewsDto.kt @@ -0,0 +1,6 @@ +package dev.pinkroom.marketsight.data.remote.model.dto.alpaca_news_api + +data class ImagesNewsDto( + val size: String, + val url: String, +) diff --git a/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/alpaca_news_api/NewsDto.kt b/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/alpaca_news_api/NewsDto.kt new file mode 100644 index 0000000..236fb7f --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/alpaca_news_api/NewsDto.kt @@ -0,0 +1,16 @@ +package dev.pinkroom.marketsight.data.remote.model.dto.alpaca_news_api + +import com.google.gson.annotations.SerializedName + +data class NewsDto( + val id: Long, + val headline: String, + val summary: String, + val author: String, + @SerializedName("created_at") val createdAt: String, + @SerializedName("updated_at") val updatedAt: String, + val url: String, + val symbols: List = emptyList(), + val source: String, + val images: List = emptyList(), +) diff --git a/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/alpaca_news_api/NewsResponseDto.kt b/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/alpaca_news_api/NewsResponseDto.kt new file mode 100644 index 0000000..e6a1998 --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/alpaca_news_api/NewsResponseDto.kt @@ -0,0 +1,8 @@ +package dev.pinkroom.marketsight.data.remote.model.dto.alpaca_news_api + +import com.google.gson.annotations.SerializedName + +data class NewsResponseDto( + val news: List, + @SerializedName("next_page_token") val nextPageToken: String, +) diff --git a/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/alpaca_news_service/ErrorMessageDto.kt b/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/alpaca_news_service/ErrorMessageDto.kt new file mode 100644 index 0000000..5899a08 --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/alpaca_news_service/ErrorMessageDto.kt @@ -0,0 +1,6 @@ +package dev.pinkroom.marketsight.data.remote.model.dto.alpaca_news_service + +data class ErrorMessageDto( + val code: Int, + val msg: String, +) diff --git a/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/NewsMessageDto.kt b/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/alpaca_news_service/NewsMessageDto.kt similarity index 82% rename from app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/NewsMessageDto.kt rename to app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/alpaca_news_service/NewsMessageDto.kt index 4a691be..d5e5531 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/NewsMessageDto.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/alpaca_news_service/NewsMessageDto.kt @@ -1,4 +1,4 @@ -package dev.pinkroom.marketsight.data.remote.model.dto +package dev.pinkroom.marketsight.data.remote.model.dto.alpaca_news_service import com.google.gson.annotations.SerializedName @@ -11,7 +11,6 @@ data class NewsMessageDto( @SerializedName("created_at") val createdAt: String, @SerializedName("updated_at") val updatedAt: String, val url: String, - val content: String, val symbols: List = emptyList(), val source: String, ) diff --git a/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/SubscriptionMessageDto.kt b/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/alpaca_news_service/SubscriptionMessageDto.kt similarity index 66% rename from app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/SubscriptionMessageDto.kt rename to app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/alpaca_news_service/SubscriptionMessageDto.kt index 34a55b6..6adc20f 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/SubscriptionMessageDto.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/alpaca_news_service/SubscriptionMessageDto.kt @@ -1,4 +1,4 @@ -package dev.pinkroom.marketsight.data.remote.model.dto +package dev.pinkroom.marketsight.data.remote.model.dto.alpaca_news_service data class SubscriptionMessageDto( val news: List? = null, diff --git a/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/TypeMessageDto.kt b/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/alpaca_news_service/TypeMessageDto.kt similarity index 62% rename from app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/TypeMessageDto.kt rename to app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/alpaca_news_service/TypeMessageDto.kt index 4bf03de..c2cfd76 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/TypeMessageDto.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/alpaca_news_service/TypeMessageDto.kt @@ -1,4 +1,4 @@ -package dev.pinkroom.marketsight.data.remote.model.dto +package dev.pinkroom.marketsight.data.remote.model.dto.alpaca_news_service import com.google.gson.annotations.SerializedName diff --git a/app/src/main/java/dev/pinkroom/marketsight/data/repository/NewsRepositoryImp.kt b/app/src/main/java/dev/pinkroom/marketsight/data/repository/NewsRepositoryImp.kt index d35287c..30934df 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/data/repository/NewsRepositoryImp.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/data/repository/NewsRepositoryImp.kt @@ -3,8 +3,10 @@ package dev.pinkroom.marketsight.data.repository import dev.pinkroom.marketsight.common.ActionAlpaca import dev.pinkroom.marketsight.common.DispatcherProvider import dev.pinkroom.marketsight.common.Resource +import dev.pinkroom.marketsight.common.SortType import dev.pinkroom.marketsight.data.data_source.AlpacaRemoteDataSource import dev.pinkroom.marketsight.data.remote.model.request.MessageAlpacaService +import dev.pinkroom.marketsight.domain.model.news.NewsResponse import dev.pinkroom.marketsight.domain.repository.NewsRepository import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn @@ -40,4 +42,18 @@ class NewsRepositoryImp @Inject constructor( } } }.flowOn(dispatchers.IO).single() + + override suspend fun getNews( + symbols: List?, + limit: Int?, + pageToken: String?, + sort: SortType? + ): Resource { + return remoteDataSource.getNews( + symbols = symbols, + limit = limit, + pageToken = pageToken, + sort = sort, + ) + } } \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/di/AppModule.kt b/app/src/main/java/dev/pinkroom/marketsight/di/AppModule.kt index d5f9874..cf240ad 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/di/AppModule.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/di/AppModule.kt @@ -20,10 +20,14 @@ import dev.pinkroom.marketsight.common.FlowStreamAdapterFactory import dev.pinkroom.marketsight.common.addAuthenticationInterceptor import dev.pinkroom.marketsight.common.addLoggingInterceptor import dev.pinkroom.marketsight.data.data_source.AlpacaRemoteDataSource -import dev.pinkroom.marketsight.data.remote.AlpacaService +import dev.pinkroom.marketsight.data.remote.AlpacaNewsApi +import dev.pinkroom.marketsight.data.remote.AlpacaNewsService import dev.pinkroom.marketsight.data.repository.NewsRepositoryImp import dev.pinkroom.marketsight.domain.repository.NewsRepository import okhttp3.OkHttpClient +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import retrofit2.create import java.util.concurrent.TimeUnit import javax.inject.Singleton @@ -51,7 +55,7 @@ object AppModule { @Provides @Singleton - fun provideAlpacaNewsService(okHttpClient: OkHttpClient, lifecycle: Lifecycle): AlpacaService { + fun provideAlpacaNewsService(okHttpClient: OkHttpClient, lifecycle: Lifecycle): AlpacaNewsService { val scarlet = Scarlet.Builder() .webSocketFactory(okHttpClient.newWebSocketFactory(ALPACA_STREAM_URL_NEWS)) .addMessageAdapterFactory(GsonMessageAdapter.Factory()) @@ -59,7 +63,18 @@ object AppModule { //.lifecycle(lifecycle) .build() - return scarlet.create(AlpacaService::class.java) + return scarlet.create(AlpacaNewsService::class.java) + } + + @Provides + @Singleton + fun provideAlpacaNewsApi(okHttpClient: OkHttpClient): AlpacaNewsApi { + return Retrofit.Builder() + .baseUrl(BuildConfig.ALPACA_DATA_URL) + .addConverterFactory(GsonConverterFactory.create()) + .client(okHttpClient) + .build() + .create() } @Provides diff --git a/app/src/main/java/dev/pinkroom/marketsight/domain/model/ErrorMessage.kt b/app/src/main/java/dev/pinkroom/marketsight/domain/model/common/ErrorMessage.kt similarity index 56% rename from app/src/main/java/dev/pinkroom/marketsight/domain/model/ErrorMessage.kt rename to app/src/main/java/dev/pinkroom/marketsight/domain/model/common/ErrorMessage.kt index fd49ed2..6910af4 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/domain/model/ErrorMessage.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/domain/model/common/ErrorMessage.kt @@ -1,4 +1,4 @@ -package dev.pinkroom.marketsight.domain.model +package dev.pinkroom.marketsight.domain.model.common data class ErrorMessage( val code: Int, diff --git a/app/src/main/java/dev/pinkroom/marketsight/domain/model/SubscriptionMessage.kt b/app/src/main/java/dev/pinkroom/marketsight/domain/model/common/SubscriptionMessage.kt similarity index 73% rename from app/src/main/java/dev/pinkroom/marketsight/domain/model/SubscriptionMessage.kt rename to app/src/main/java/dev/pinkroom/marketsight/domain/model/common/SubscriptionMessage.kt index 9922f47..fe1aacb 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/domain/model/SubscriptionMessage.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/domain/model/common/SubscriptionMessage.kt @@ -1,4 +1,4 @@ -package dev.pinkroom.marketsight.domain.model +package dev.pinkroom.marketsight.domain.model.common data class SubscriptionMessage( val news: List? = null, diff --git a/app/src/main/java/dev/pinkroom/marketsight/domain/model/news/ImageSize.kt b/app/src/main/java/dev/pinkroom/marketsight/domain/model/news/ImageSize.kt new file mode 100644 index 0000000..cdba1e7 --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/domain/model/news/ImageSize.kt @@ -0,0 +1,7 @@ +package dev.pinkroom.marketsight.domain.model.news + +sealed interface ImageSize { + data object Large: ImageSize + data object Small: ImageSize + data object Thumb: ImageSize +} \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/domain/model/news/ImagesNews.kt b/app/src/main/java/dev/pinkroom/marketsight/domain/model/news/ImagesNews.kt new file mode 100644 index 0000000..8996353 --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/domain/model/news/ImagesNews.kt @@ -0,0 +1,6 @@ +package dev.pinkroom.marketsight.domain.model.news + +data class ImagesNews( + val size: ImageSize, + val url: String, +) diff --git a/app/src/main/java/dev/pinkroom/marketsight/domain/model/NewsInfo.kt b/app/src/main/java/dev/pinkroom/marketsight/domain/model/news/NewsInfo.kt similarity index 75% rename from app/src/main/java/dev/pinkroom/marketsight/domain/model/NewsInfo.kt rename to app/src/main/java/dev/pinkroom/marketsight/domain/model/news/NewsInfo.kt index cdc979c..db4182b 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/domain/model/NewsInfo.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/domain/model/news/NewsInfo.kt @@ -1,4 +1,4 @@ -package dev.pinkroom.marketsight.domain.model +package dev.pinkroom.marketsight.domain.model.news import java.time.LocalDateTime @@ -12,4 +12,5 @@ data class NewsInfo( val url: String, val symbols: List, val source: String, + val images: List? = null, ) diff --git a/app/src/main/java/dev/pinkroom/marketsight/domain/model/news/NewsResponse.kt b/app/src/main/java/dev/pinkroom/marketsight/domain/model/news/NewsResponse.kt new file mode 100644 index 0000000..e8f6933 --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/domain/model/news/NewsResponse.kt @@ -0,0 +1,6 @@ +package dev.pinkroom.marketsight.domain.model.news + +data class NewsResponse( + val news: List, + val nextPageToken: String, +) diff --git a/app/src/main/java/dev/pinkroom/marketsight/domain/repository/NewsRepository.kt b/app/src/main/java/dev/pinkroom/marketsight/domain/repository/NewsRepository.kt index bbb50c1..dd2f56c 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/domain/repository/NewsRepository.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/domain/repository/NewsRepository.kt @@ -3,7 +3,9 @@ package dev.pinkroom.marketsight.domain.repository import com.tinder.scarlet.WebSocket import dev.pinkroom.marketsight.common.ActionAlpaca import dev.pinkroom.marketsight.common.Resource -import dev.pinkroom.marketsight.domain.model.NewsInfo +import dev.pinkroom.marketsight.common.SortType +import dev.pinkroom.marketsight.domain.model.news.NewsInfo +import dev.pinkroom.marketsight.domain.model.news.NewsResponse import kotlinx.coroutines.flow.Flow interface NewsRepository { @@ -15,4 +17,11 @@ interface NewsRepository { symbols: List, actionAlpaca: ActionAlpaca, ): Resource> + + suspend fun getNews( + symbols: List? = null, + limit: Int? = null, + pageToken: String? = null, + sort: SortType? = SortType.DESC, + ): Resource } \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/domain/use_case/news/GetNews.kt b/app/src/main/java/dev/pinkroom/marketsight/domain/use_case/news/GetNews.kt new file mode 100644 index 0000000..42e6cca --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/domain/use_case/news/GetNews.kt @@ -0,0 +1,12 @@ +package dev.pinkroom.marketsight.domain.use_case.news + +import dev.pinkroom.marketsight.domain.repository.NewsRepository +import javax.inject.Inject + +class GetNews @Inject constructor( + private val newsRepository: NewsRepository, +){ + suspend operator fun invoke(){ + newsRepository.getNews() + } +} \ No newline at end of file From b539b0cb5cbb9f24abf62f19d0d683ec5da1f0b5 Mon Sep 17 00:00:00 2001 From: LucasPrioste Date: Thu, 11 Apr 2024 16:21:16 +0100 Subject: [PATCH 10/24] - alpaca news API --- gradle/libs.versions.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 70d6491..0b8bbcb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,7 +15,7 @@ hiltVersion = "2.51.1" hiltNavigationVersion = "1.2.0" splashVersion = "1.0.1" jsonVersion = "1.6.0" -jsonPluginVersion = "1.9.23" +desugarVersion = "2.0.4" [libraries] @@ -26,6 +26,7 @@ androidx-ui = { group = "androidx.compose.ui", name = "ui" } androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview"} androidx-material3 = { group = "androidx.compose.material3", name = "material3" } +androidx-desugar = { group = "com.android.tools", name = "desugar_jdk_libs", version.ref = "desugarVersion" } # COMPOSE androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" } From 27987744f73175ba751a1ac942ab5e61254e3c7e Mon Sep 17 00:00:00 2001 From: LucasPrioste Date: Thu, 11 Apr 2024 18:32:32 +0100 Subject: [PATCH 11/24] - setup Unit Test libs - assertK - mockK - JUnit - coroutine test --- app/build.gradle.kts | 5 ++++- .../dev/pinkroom/marketsight/ExampleUnitTest.kt | 17 ----------------- .../data_source/AlpacaRemoteDataSourceTest.kt | 4 ++++ gradle/libs.versions.toml | 17 +++++++++++++---- 4 files changed, 21 insertions(+), 22 deletions(-) delete mode 100644 app/src/test/java/dev/pinkroom/marketsight/ExampleUnitTest.kt create mode 100644 app/src/test/java/dev/pinkroom/marketsight/data/data_source/AlpacaRemoteDataSourceTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c8fcf6f..49b348d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -113,7 +113,10 @@ dependencies { // UNIT TEST testImplementation(libs.junit) - testImplementation(libs.junit.api) + testImplementation(libs.mockk.android) + testImplementation(libs.mockk.agent) + testImplementation(libs.coroutine.test) + testImplementation(libs.assertk) // END-TO-END TEST androidTestImplementation(libs.androidx.junit) diff --git a/app/src/test/java/dev/pinkroom/marketsight/ExampleUnitTest.kt b/app/src/test/java/dev/pinkroom/marketsight/ExampleUnitTest.kt deleted file mode 100644 index ab046ad..0000000 --- a/app/src/test/java/dev/pinkroom/marketsight/ExampleUnitTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package dev.pinkroom.marketsight - -import org.junit.Test - -import org.junit.Assert.* - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} \ No newline at end of file diff --git a/app/src/test/java/dev/pinkroom/marketsight/data/data_source/AlpacaRemoteDataSourceTest.kt b/app/src/test/java/dev/pinkroom/marketsight/data/data_source/AlpacaRemoteDataSourceTest.kt new file mode 100644 index 0000000..a990efb --- /dev/null +++ b/app/src/test/java/dev/pinkroom/marketsight/data/data_source/AlpacaRemoteDataSourceTest.kt @@ -0,0 +1,4 @@ +package dev.pinkroom.marketsight.data.data_source + +class AlpacaRemoteDataSourceTest{ +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0b8bbcb..7013a6f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,7 +2,6 @@ agp = "8.3.1" kotlin = "1.9.22" coreKtx = "1.12.0" -junit = "5.10.2" junitVersion = "1.1.5" espressoCore = "3.5.1" lifecycleRuntimeKtx = "2.7.0" @@ -16,6 +15,10 @@ hiltNavigationVersion = "1.2.0" splashVersion = "1.0.1" jsonVersion = "1.6.0" desugarVersion = "2.0.4" +junit4 = "4.13.2" +mockkVersion = "1.13.10" +coroutineVersion = "1.8.0" +assertkVersion = "0.28.0" [libraries] @@ -39,7 +42,6 @@ retrofit-gson = { group = "com.squareup.retrofit2", name = "converter-gson", ver # JSON json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "jsonVersion" } - # OkHTTP okhttp-core = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okHttpVersion" } okhttp-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okHttpVersion" } @@ -61,8 +63,15 @@ hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.r splash = { group = "androidx.core", name = "core-splashscreen", version.ref= "splashVersion" } # UNIT TEST -junit = { group = "org.junit.jupiter", name = "junit-jupiter-engine", version.ref = "junit" } -junit-api = { group = "org.junit.jupiter", name = "junit-jupiter-api", version.ref = "junit" } +junit = { group = "junit", name = "junit", version.ref = "junit4" } +coroutine-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutineVersion" } + +# ASSERTK +assertk = { group = "com.willowtreeapps.assertk", name = "assertk", version.ref = "assertkVersion"} + +# MOCKK +mockk-android = { group = "io.mockk", name = "mockk-android", version.ref = "mockkVersion" } +mockk-agent = { group = "io.mockk", name = "mockk-agent", version.ref = "mockkVersion" } # END-TO-END TEST androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } From d26ca4fc6229ffb050631b8ff90cee34e3aef524 Mon Sep 17 00:00:00 2001 From: LucasPrioste Date: Fri, 12 Apr 2024 15:43:55 +0100 Subject: [PATCH 12/24] - faker dependency - unit test (NewsRemoteDataSource) - refactor code --- app/build.gradle.kts | 1 + ...eDataSource.kt => NewsRemoteDataSource.kt} | 4 +- .../alpaca_news_service/ErrorMessageDto.kt | 3 + .../dto/alpaca_news_service/NewsMessageDto.kt | 1 + .../SubscriptionMessageDto.kt | 3 + .../model/request/MessageAlpacaService.kt | 6 +- .../data/repository/NewsRepositoryImp.kt | 12 +- .../dev/pinkroom/marketsight/di/AppModule.kt | 6 +- .../domain/use_case/news/GetNews.kt | 4 +- .../domain/use_case/news/GetRealTimeNews.kt | 5 +- .../domain/use_case/news/SubscribeNews.kt | 5 +- .../ui/home_screen/HomeViewModel.kt | 1 - .../data_source/AlpacaRemoteDataSourceTest.kt | 4 - .../data_source/NewsRemoteDataSourceTest.kt | 288 ++++++++++++++++++ .../marketsight/factories/BaseFactory.kt | 6 + .../marketsight/factories/ImagesFactory.kt | 19 ++ .../marketsight/factories/NewsDtoFactory.kt | 22 ++ .../marketsight/factories/NewsFactory.kt | 22 ++ .../marketsight/util/MainCoroutineRule.kt | 26 ++ .../util/TestDispatcherProvider.kt | 16 + gradle/libs.versions.toml | 6 +- 21 files changed, 429 insertions(+), 31 deletions(-) rename app/src/main/java/dev/pinkroom/marketsight/data/data_source/{AlpacaRemoteDataSource.kt => NewsRemoteDataSource.kt} (96%) delete mode 100644 app/src/test/java/dev/pinkroom/marketsight/data/data_source/AlpacaRemoteDataSourceTest.kt create mode 100644 app/src/test/java/dev/pinkroom/marketsight/data/data_source/NewsRemoteDataSourceTest.kt create mode 100644 app/src/test/java/dev/pinkroom/marketsight/factories/BaseFactory.kt create mode 100644 app/src/test/java/dev/pinkroom/marketsight/factories/ImagesFactory.kt create mode 100644 app/src/test/java/dev/pinkroom/marketsight/factories/NewsDtoFactory.kt create mode 100644 app/src/test/java/dev/pinkroom/marketsight/factories/NewsFactory.kt create mode 100644 app/src/test/java/dev/pinkroom/marketsight/util/MainCoroutineRule.kt create mode 100644 app/src/test/java/dev/pinkroom/marketsight/util/TestDispatcherProvider.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 49b348d..ecc4305 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -117,6 +117,7 @@ dependencies { testImplementation(libs.mockk.agent) testImplementation(libs.coroutine.test) testImplementation(libs.assertk) + testImplementation(libs.faker) // END-TO-END TEST androidTestImplementation(libs.androidx.junit) diff --git a/app/src/main/java/dev/pinkroom/marketsight/data/data_source/AlpacaRemoteDataSource.kt b/app/src/main/java/dev/pinkroom/marketsight/data/data_source/NewsRemoteDataSource.kt similarity index 96% rename from app/src/main/java/dev/pinkroom/marketsight/data/data_source/AlpacaRemoteDataSource.kt rename to app/src/main/java/dev/pinkroom/marketsight/data/data_source/NewsRemoteDataSource.kt index 1955882..28f601d 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/data/data_source/AlpacaRemoteDataSource.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/data/data_source/NewsRemoteDataSource.kt @@ -26,7 +26,7 @@ import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.take import javax.inject.Inject -class AlpacaRemoteDataSource @Inject constructor( +class NewsRemoteDataSource @Inject constructor( private val gson: Gson, private val alpacaNewsService: AlpacaNewsService, private val alpacaNewsApi: AlpacaNewsApi, @@ -68,7 +68,7 @@ class AlpacaRemoteDataSource @Inject constructor( } }.flowOn(dispatchers.IO) - fun sendMessageToAlpacaService(message: MessageAlpacaService) = flow> { + fun sendSubscribeMessageToAlpacaService(message: MessageAlpacaService) = flow> { alpacaNewsService.sendMessage(message = message) alpacaNewsService.observeResponse().collect { data -> data.forEach { diff --git a/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/alpaca_news_service/ErrorMessageDto.kt b/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/alpaca_news_service/ErrorMessageDto.kt index 5899a08..1448e51 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/alpaca_news_service/ErrorMessageDto.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/alpaca_news_service/ErrorMessageDto.kt @@ -1,6 +1,9 @@ package dev.pinkroom.marketsight.data.remote.model.dto.alpaca_news_service +import com.google.gson.annotations.SerializedName + data class ErrorMessageDto( + @SerializedName("T") val type: String, val code: Int, val msg: String, ) diff --git a/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/alpaca_news_service/NewsMessageDto.kt b/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/alpaca_news_service/NewsMessageDto.kt index d5e5531..fe7b1f2 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/alpaca_news_service/NewsMessageDto.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/alpaca_news_service/NewsMessageDto.kt @@ -4,6 +4,7 @@ import com.google.gson.annotations.SerializedName data class NewsMessageDto( + @SerializedName("T") val type: String, val id: Long, val headline: String, val summary: String, diff --git a/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/alpaca_news_service/SubscriptionMessageDto.kt b/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/alpaca_news_service/SubscriptionMessageDto.kt index 6adc20f..ccbd2d3 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/alpaca_news_service/SubscriptionMessageDto.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/dto/alpaca_news_service/SubscriptionMessageDto.kt @@ -1,6 +1,9 @@ package dev.pinkroom.marketsight.data.remote.model.dto.alpaca_news_service +import com.google.gson.annotations.SerializedName + data class SubscriptionMessageDto( + @SerializedName("T") val type: String, val news: List? = null, val quotes: List? = null, val trades: List? = null, diff --git a/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/request/MessageAlpacaService.kt b/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/request/MessageAlpacaService.kt index 7e3cd8a..ea1a837 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/request/MessageAlpacaService.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/data/remote/model/request/MessageAlpacaService.kt @@ -2,7 +2,7 @@ package dev.pinkroom.marketsight.data.remote.model.request data class MessageAlpacaService( val action: String, // subscribe / unsubscribe - val trades: List = emptyList(), - val quotes: List = emptyList(), - val news: List = emptyList(), + val trades: List? = null, + val quotes: List? = null, + val news: List? = null, ) diff --git a/app/src/main/java/dev/pinkroom/marketsight/data/repository/NewsRepositoryImp.kt b/app/src/main/java/dev/pinkroom/marketsight/data/repository/NewsRepositoryImp.kt index 30934df..4b54717 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/data/repository/NewsRepositoryImp.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/data/repository/NewsRepositoryImp.kt @@ -4,7 +4,7 @@ import dev.pinkroom.marketsight.common.ActionAlpaca import dev.pinkroom.marketsight.common.DispatcherProvider import dev.pinkroom.marketsight.common.Resource import dev.pinkroom.marketsight.common.SortType -import dev.pinkroom.marketsight.data.data_source.AlpacaRemoteDataSource +import dev.pinkroom.marketsight.data.data_source.NewsRemoteDataSource import dev.pinkroom.marketsight.data.remote.model.request.MessageAlpacaService import dev.pinkroom.marketsight.domain.model.news.NewsResponse import dev.pinkroom.marketsight.domain.repository.NewsRepository @@ -14,17 +14,17 @@ import kotlinx.coroutines.flow.single import javax.inject.Inject class NewsRepositoryImp @Inject constructor( - private val remoteDataSource: AlpacaRemoteDataSource, + private val newsRemoteDataSource: NewsRemoteDataSource, private val dispatchers: DispatcherProvider, ): NewsRepository { override fun subscribeNews(symbols: List) = flow { - remoteDataSource.subscribeNews(symbols = symbols).collect{ + newsRemoteDataSource.subscribeNews(symbols = symbols).collect{ emit(it) } }.flowOn(dispatchers.IO) override fun getRealTimeNews() = flow { - remoteDataSource.getRealTimeNews().collect{ + newsRemoteDataSource.getRealTimeNews().collect{ emit(it) } }.flowOn(dispatchers.IO) @@ -34,7 +34,7 @@ class NewsRepositoryImp @Inject constructor( actionAlpaca: ActionAlpaca, ): Resource> = flow { val message = MessageAlpacaService(action = actionAlpaca.action, news = symbols) - remoteDataSource.sendMessageToAlpacaService(message = message).collect{ response -> + newsRemoteDataSource.sendSubscribeMessageToAlpacaService(message = message).collect{ response -> when(response){ is Resource.Error -> emit(Resource.Error(data = symbols)) is Resource.Success -> emit(Resource.Success(data = response.data.news ?: symbols)) @@ -49,7 +49,7 @@ class NewsRepositoryImp @Inject constructor( pageToken: String?, sort: SortType? ): Resource { - return remoteDataSource.getNews( + return newsRemoteDataSource.getNews( symbols = symbols, limit = limit, pageToken = pageToken, diff --git a/app/src/main/java/dev/pinkroom/marketsight/di/AppModule.kt b/app/src/main/java/dev/pinkroom/marketsight/di/AppModule.kt index cf240ad..2896c7d 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/di/AppModule.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/di/AppModule.kt @@ -19,7 +19,7 @@ import dev.pinkroom.marketsight.common.DispatcherProvider import dev.pinkroom.marketsight.common.FlowStreamAdapterFactory import dev.pinkroom.marketsight.common.addAuthenticationInterceptor import dev.pinkroom.marketsight.common.addLoggingInterceptor -import dev.pinkroom.marketsight.data.data_source.AlpacaRemoteDataSource +import dev.pinkroom.marketsight.data.data_source.NewsRemoteDataSource import dev.pinkroom.marketsight.data.remote.AlpacaNewsApi import dev.pinkroom.marketsight.data.remote.AlpacaNewsService import dev.pinkroom.marketsight.data.repository.NewsRepositoryImp @@ -89,7 +89,7 @@ object AppModule { @Provides @Singleton - fun provideNewsRepository(alpacaRemoteDataSource: AlpacaRemoteDataSource, dispatcherProvider: DispatcherProvider): NewsRepository { - return NewsRepositoryImp(remoteDataSource = alpacaRemoteDataSource, dispatchers = dispatcherProvider) + fun provideNewsRepository(newsRemoteDataSource: NewsRemoteDataSource, dispatcherProvider: DispatcherProvider): NewsRepository { + return NewsRepositoryImp(newsRemoteDataSource = newsRemoteDataSource, dispatchers = dispatcherProvider) } } \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/domain/use_case/news/GetNews.kt b/app/src/main/java/dev/pinkroom/marketsight/domain/use_case/news/GetNews.kt index 42e6cca..852c9aa 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/domain/use_case/news/GetNews.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/domain/use_case/news/GetNews.kt @@ -6,7 +6,5 @@ import javax.inject.Inject class GetNews @Inject constructor( private val newsRepository: NewsRepository, ){ - suspend operator fun invoke(){ - newsRepository.getNews() - } + suspend operator fun invoke() = newsRepository.getNews() } \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/domain/use_case/news/GetRealTimeNews.kt b/app/src/main/java/dev/pinkroom/marketsight/domain/use_case/news/GetRealTimeNews.kt index 2229ddb..625119a 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/domain/use_case/news/GetRealTimeNews.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/domain/use_case/news/GetRealTimeNews.kt @@ -2,7 +2,6 @@ package dev.pinkroom.marketsight.domain.use_case.news import dev.pinkroom.marketsight.common.DispatcherProvider import dev.pinkroom.marketsight.domain.repository.NewsRepository -import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import javax.inject.Inject @@ -10,7 +9,5 @@ class GetRealTimeNews @Inject constructor( private val newsRepository: NewsRepository, private val dispatchers: DispatcherProvider, ){ - operator fun invoke() = flow { - emit(newsRepository.getRealTimeNews()) - }.flowOn(dispatchers.IO) + operator fun invoke() = newsRepository.getRealTimeNews().flowOn(dispatchers.IO) } \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/domain/use_case/news/SubscribeNews.kt b/app/src/main/java/dev/pinkroom/marketsight/domain/use_case/news/SubscribeNews.kt index 79ca029..68b70b4 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/domain/use_case/news/SubscribeNews.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/domain/use_case/news/SubscribeNews.kt @@ -2,7 +2,6 @@ package dev.pinkroom.marketsight.domain.use_case.news import dev.pinkroom.marketsight.common.DispatcherProvider import dev.pinkroom.marketsight.domain.repository.NewsRepository -import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import javax.inject.Inject @@ -10,7 +9,5 @@ class SubscribeNews @Inject constructor( private val newsRepository: NewsRepository, private val dispatchers: DispatcherProvider, ){ - operator fun invoke() = flow { - emit(newsRepository.subscribeNews()) - }.flowOn(dispatchers.IO) + operator fun invoke() = newsRepository.subscribeNews().flowOn(dispatchers.IO) } \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/home_screen/HomeViewModel.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/home_screen/HomeViewModel.kt index 54bdcf1..5421ff5 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/home_screen/HomeViewModel.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/home_screen/HomeViewModel.kt @@ -7,5 +7,4 @@ import javax.inject.Inject @HiltViewModel class HomeViewModel @Inject constructor( ): ViewModel(){ - } \ No newline at end of file diff --git a/app/src/test/java/dev/pinkroom/marketsight/data/data_source/AlpacaRemoteDataSourceTest.kt b/app/src/test/java/dev/pinkroom/marketsight/data/data_source/AlpacaRemoteDataSourceTest.kt deleted file mode 100644 index a990efb..0000000 --- a/app/src/test/java/dev/pinkroom/marketsight/data/data_source/AlpacaRemoteDataSourceTest.kt +++ /dev/null @@ -1,4 +0,0 @@ -package dev.pinkroom.marketsight.data.data_source - -class AlpacaRemoteDataSourceTest{ -} \ No newline at end of file diff --git a/app/src/test/java/dev/pinkroom/marketsight/data/data_source/NewsRemoteDataSourceTest.kt b/app/src/test/java/dev/pinkroom/marketsight/data/data_source/NewsRemoteDataSourceTest.kt new file mode 100644 index 0000000..7a1e206 --- /dev/null +++ b/app/src/test/java/dev/pinkroom/marketsight/data/data_source/NewsRemoteDataSourceTest.kt @@ -0,0 +1,288 @@ +package dev.pinkroom.marketsight.data.data_source + +import assertk.assertThat +import assertk.assertions.any +import assertk.assertions.isEqualTo +import assertk.assertions.isNotEmpty +import assertk.assertions.isNull +import assertk.assertions.isTrue +import com.github.javafaker.Faker +import com.google.gson.Gson +import com.tinder.scarlet.WebSocket +import dev.pinkroom.marketsight.common.ActionAlpaca +import dev.pinkroom.marketsight.common.Resource +import dev.pinkroom.marketsight.common.SortType +import dev.pinkroom.marketsight.data.remote.AlpacaNewsApi +import dev.pinkroom.marketsight.data.remote.AlpacaNewsService +import dev.pinkroom.marketsight.data.remote.model.dto.alpaca_news_api.NewsResponseDto +import dev.pinkroom.marketsight.data.remote.model.dto.alpaca_news_service.ErrorMessageDto +import dev.pinkroom.marketsight.data.remote.model.dto.alpaca_news_service.NewsMessageDto +import dev.pinkroom.marketsight.data.remote.model.dto.alpaca_news_service.SubscriptionMessageDto +import dev.pinkroom.marketsight.data.remote.model.request.MessageAlpacaService +import dev.pinkroom.marketsight.factories.NewsDtoFactory +import dev.pinkroom.marketsight.factories.NewsFactory +import dev.pinkroom.marketsight.util.MainCoroutineRule +import dev.pinkroom.marketsight.util.TestDispatcherProvider +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +@ExperimentalCoroutinesApi +class NewsRemoteDataSourceTest { + + @get:Rule + val coroutineRule = MainCoroutineRule() + + private val gson = Gson() + private val faker = Faker() + private val newsFactory = NewsFactory() + private val newsDtoFactory = NewsDtoFactory() + private val alpacaNewsService = mockk(relaxed = true, relaxUnitFun = true) + private val alpacaNewsApi = mockk() + private val dispatchers = TestDispatcherProvider() + private val alpacaRemoteDataSource = NewsRemoteDataSource( + gson = gson, + alpacaNewsService = alpacaNewsService, + alpacaNewsApi = alpacaNewsApi, + dispatchers = dispatchers, + ) + + @Test + fun `Given params, when init subscribe news with all topics, then sendMessage is called with correct params`() = runTest { + // GIVEN + mockStartAlpacaServiceWithSuccess() + val symbols = listOf("*") + + // WHEN + alpacaRemoteDataSource.subscribeNews(symbols = symbols).firstOrNull() + + // THEN + val expectedMessageArgs = MessageAlpacaService(action = ActionAlpaca.Subscribe.action, news = symbols) + verify { alpacaNewsService.sendMessage(message = expectedMessageArgs) } + } + + @Test + fun `Given params, when send subscribe message to service, then sendMessage is called with correct params`() = runTest { + // GIVEN + val messageToSend = MessageAlpacaService(action = ActionAlpaca.Subscribe.action, news = listOf("TSLA","AAPL"), quotes = listOf("TSLA")) + mockMessageSubscriptionServiceWithSuccess(messageToSend) + + // WHEN + val response = alpacaRemoteDataSource.sendSubscribeMessageToAlpacaService(message = messageToSend).first() + + // THEN + verify { alpacaNewsService.sendMessage(message = messageToSend) } + assertThat(response is Resource.Success).isTrue() + val data = response as Resource.Success + assertThat(data.data.news).isEqualTo(messageToSend.news) + assertThat(data.data.quotes).isEqualTo(messageToSend.quotes) + assertThat(data.data.trades).isEqualTo(messageToSend.trades) + } + + @Test + fun `When receive message on getRealTimeNews, then try to parse Json to NewsInfo class`() = runTest { + // GIVEN + val listNews = newsFactory.buildList() + mockNewsServiceWithSuccess(newsList = listNews) + + // WHEN + val response = alpacaRemoteDataSource.getRealTimeNews().toList() + + // THEN + verify { alpacaNewsService.observeResponse() } + val allIdsSent = listNews.map { it.id } + assertThat(response).isNotEmpty() + response.forEach { newsReturned -> + assertThat(newsReturned).isNotEmpty() + newsReturned.forEach { news -> + assertThat(allIdsSent).any { it.isEqualTo(news.id)} + } + } + } + + @Test + fun `Given params, then alpaca api call getNews is called with correct params`() = runTest { + // GIVEN + val symbols = listOf("*") + val limit = 15 + val pageToken = null + val sort = SortType.DESC + mockNewsResponseApiWithSuccess(limit) + + // WHEN + val response = alpacaRemoteDataSource.getNews( + symbols = symbols, + limit = limit, + pageToken = pageToken, + sort = sort + ) + + // THEN + coVerify { + alpacaNewsApi.getNews( + symbols = symbols.joinToString(","), + perPage = limit, + pageToken = pageToken, + sort = sort.type, + ) + } + assertThat(response is Resource.Success).isTrue() + val data = response as Resource.Success + assertThat(data.data.news.size).isEqualTo(limit) + } + + @Test + fun `Given params, then alpaca api throw error on call getNews`() = runTest { + // GIVEN + mockNewsResponseApiWithError() + + // WHEN + val response = alpacaRemoteDataSource.getNews( + symbols = null, + limit = null, + pageToken = null, + sort = null + ) + + // THEN + coVerify { + alpacaNewsApi.getNews( + symbols = any(), + pageToken = any(), + sort = any(), + perPage = any(), + ) + } + assertThat(response is Resource.Error).isTrue() + } + + @Test + fun `When receive message from WS, then message is not related with news on getRealTimeNews`() = runTest { + // GIVEN + mockNewsServiceWithMessageNotExpected() + + // WHEN + val response = alpacaRemoteDataSource.getRealTimeNews().firstOrNull() + + // THEN + verify { alpacaNewsService.observeResponse() } + assertThat(response).isNull() + } + + @Test + fun `When receive message from WS, then alpaca service receive error message on sendSubscribeMessageToAlpacaService`() = runTest { + // GIVEN + val messageToSend = MessageAlpacaService(action = ActionAlpaca.Subscribe.action, news = listOf("TSLA","AAPL"), quotes = listOf("TSLA")) + mockMessageSubscriptionServiceWithError() + + // WHEN + val response = alpacaRemoteDataSource.sendSubscribeMessageToAlpacaService(messageToSend).first() + + // THEN + verify { alpacaNewsService.observeResponse() } + assertThat(response is Resource.Error).isTrue() + } + + private fun mockNewsResponseApiWithError() { + coEvery { alpacaNewsApi.getNews( + symbols = any(), + perPage = any(), + pageToken = any(), + sort = any(), + ) }.throws( + Exception("Test Error") + ) + } + + private fun mockNewsResponseApiWithSuccess(limit: Int) { + coEvery { alpacaNewsApi.getNews( + symbols = any(), + perPage = any(), + pageToken = any(), + sort = any(), + ) }.returns( + NewsResponseDto( + news = newsDtoFactory.buildList(number = limit), + nextPageToken = faker.lorem().word() + ) + ) + } + + private fun mockNewsServiceWithSuccess(newsList: List) { + every { alpacaNewsService.observeResponse() }.returns( + flow { + emit(listOf(gson.toJsonTree(newsList.first()))) + delay(3000) // Simulate WS API + val listNewsJson = newsList.map { + gson.toJsonTree(it) + } + emit(listNewsJson) + } + ) + } + + private fun mockNewsServiceWithMessageNotExpected() { + every { alpacaNewsService.observeResponse() }.returns( + flow { + emit(listOf(gson.toJsonTree( + ErrorMessageDto( + type = "error", + code = 403, + msg = "Not authenticated" + ) + ))) + } + ) + } + + private fun mockStartAlpacaServiceWithSuccess() { + every { alpacaNewsService.sendMessage(any()) }.returns(Unit) + + every { alpacaNewsService.observeOnConnectionEvent() }.returns( + flow { + emit(WebSocket.Event.OnConnectionOpened(webSocket = Any())) + } + ) + } + + private fun mockMessageSubscriptionServiceWithSuccess(messageAlpacaService: MessageAlpacaService) { + val returnedMessageService = SubscriptionMessageDto( + type = "subscription", + news = messageAlpacaService.news, + quotes = messageAlpacaService.quotes, + trades = messageAlpacaService.trades, + ) + + every { alpacaNewsService.sendMessage(any()) } returns(Unit) + every { alpacaNewsService.observeResponse() }.returns( + flow { + emit(listOf(gson.toJsonTree(returnedMessageService))) + } + ) + } + + private fun mockMessageSubscriptionServiceWithError() { + val errorMessage = ErrorMessageDto( + type = "error", + msg = "Not Found", + code = 404, + ) + every { alpacaNewsService.sendMessage(any()) } returns(Unit) + every { alpacaNewsService.observeResponse() }.returns( + flow { + emit(listOf(gson.toJsonTree(errorMessage))) + } + ) + } +} \ No newline at end of file diff --git a/app/src/test/java/dev/pinkroom/marketsight/factories/BaseFactory.kt b/app/src/test/java/dev/pinkroom/marketsight/factories/BaseFactory.kt new file mode 100644 index 0000000..099b918 --- /dev/null +++ b/app/src/test/java/dev/pinkroom/marketsight/factories/BaseFactory.kt @@ -0,0 +1,6 @@ +package dev.pinkroom.marketsight.factories + +interface BaseFactory { + fun build(): T + fun buildList(number: Int = 3) = List(number) { build() } +} \ No newline at end of file diff --git a/app/src/test/java/dev/pinkroom/marketsight/factories/ImagesFactory.kt b/app/src/test/java/dev/pinkroom/marketsight/factories/ImagesFactory.kt new file mode 100644 index 0000000..97fce1d --- /dev/null +++ b/app/src/test/java/dev/pinkroom/marketsight/factories/ImagesFactory.kt @@ -0,0 +1,19 @@ +package dev.pinkroom.marketsight.factories + +import com.github.javafaker.Faker +import dev.pinkroom.marketsight.data.remote.model.dto.alpaca_news_api.ImagesNewsDto +import kotlin.random.Random + +class ImagesFactory: BaseFactory { + + private val faker = Faker() + override fun build(): ImagesNewsDto = ImagesNewsDto( + size = getImageSizeRandom(), + url = faker.internet().url() + ) + + private fun getImageSizeRandom(): String { + val sizeValues = listOf("large","small","thumb") + return sizeValues[Random.nextInt(from = 0, until = sizeValues.size)] + } +} \ No newline at end of file diff --git a/app/src/test/java/dev/pinkroom/marketsight/factories/NewsDtoFactory.kt b/app/src/test/java/dev/pinkroom/marketsight/factories/NewsDtoFactory.kt new file mode 100644 index 0000000..4d6b0ae --- /dev/null +++ b/app/src/test/java/dev/pinkroom/marketsight/factories/NewsDtoFactory.kt @@ -0,0 +1,22 @@ +package dev.pinkroom.marketsight.factories + +import com.github.javafaker.Faker +import dev.pinkroom.marketsight.data.remote.model.dto.alpaca_news_api.NewsDto + +class NewsDtoFactory: BaseFactory { + + private val faker = Faker() + private val imagesFactory = ImagesFactory() + override fun build() = NewsDto( + id = faker.number().randomNumber(), + headline = faker.lorem().sentence(), + summary = faker.lorem().paragraph(), + author = faker.name().fullName(), + createdAt = "2024-04-11T13:45:17Z", + updatedAt = "2024-04-11T13:45:17Z", + url = faker.internet().url(), + symbols = listOf(faker.stock().nsdqSymbol()), + source = faker.lorem().word(), + images = imagesFactory.buildList(), + ) +} \ No newline at end of file diff --git a/app/src/test/java/dev/pinkroom/marketsight/factories/NewsFactory.kt b/app/src/test/java/dev/pinkroom/marketsight/factories/NewsFactory.kt new file mode 100644 index 0000000..f537e6c --- /dev/null +++ b/app/src/test/java/dev/pinkroom/marketsight/factories/NewsFactory.kt @@ -0,0 +1,22 @@ +package dev.pinkroom.marketsight.factories + +import com.github.javafaker.Faker +import dev.pinkroom.marketsight.data.remote.model.dto.alpaca_news_service.NewsMessageDto + +class NewsFactory: BaseFactory { + + private val faker = Faker() + override fun build() = NewsMessageDto( + author = faker.name().fullName(), + symbols = listOf(faker.stock().nsdqSymbol()), + url = faker.internet().url(), + updatedAt = "2024-04-11T13:45:17Z", + summary = faker.lorem().sentence(), + source = faker.lorem().word(), + id = faker.number().randomNumber(), + headline = faker.lorem().sentence(), + createdAt = "2024-04-11T13:45:17Z", + type = "n", + ) + +} \ No newline at end of file diff --git a/app/src/test/java/dev/pinkroom/marketsight/util/MainCoroutineRule.kt b/app/src/test/java/dev/pinkroom/marketsight/util/MainCoroutineRule.kt new file mode 100644 index 0000000..394a011 --- /dev/null +++ b/app/src/test/java/dev/pinkroom/marketsight/util/MainCoroutineRule.kt @@ -0,0 +1,26 @@ +package dev.pinkroom.marketsight.util + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +@ExperimentalCoroutinesApi +class MainCoroutineRule( + private val dispatcher: TestDispatcher = StandardTestDispatcher() +) : TestWatcher() { + + override fun starting(description: Description?) { + super.starting(description) + Dispatchers.setMain(dispatcher) + } + + override fun finished(description: Description?) { + super.finished(description) + Dispatchers.resetMain() + } +} \ No newline at end of file diff --git a/app/src/test/java/dev/pinkroom/marketsight/util/TestDispatcherProvider.kt b/app/src/test/java/dev/pinkroom/marketsight/util/TestDispatcherProvider.kt new file mode 100644 index 0000000..4841549 --- /dev/null +++ b/app/src/test/java/dev/pinkroom/marketsight/util/TestDispatcherProvider.kt @@ -0,0 +1,16 @@ +package dev.pinkroom.marketsight.util + +import dev.pinkroom.marketsight.common.DispatcherProvider +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers + +class TestDispatcherProvider( + private val testDispatcher: CoroutineDispatcher = Dispatchers.Main +) : DispatcherProvider { + override val Main: CoroutineDispatcher + get() = testDispatcher + override val IO: CoroutineDispatcher + get() = testDispatcher + override val Default: CoroutineDispatcher + get() = testDispatcher +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7013a6f..9f7d112 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,6 +19,7 @@ junit4 = "4.13.2" mockkVersion = "1.13.10" coroutineVersion = "1.8.0" assertkVersion = "0.28.0" +fakerVersion = "1.0.2" [libraries] @@ -60,12 +61,15 @@ hilt-navigation = { group = "androidx.hilt", name = "hilt-navigation-compose", v hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.ref = "hiltVersion" } # SPLASH -splash = { group = "androidx.core", name = "core-splashscreen", version.ref= "splashVersion" } +splash = { group = "androidx.core", name = "core-splashscreen", version.ref = "splashVersion" } # UNIT TEST junit = { group = "junit", name = "junit", version.ref = "junit4" } coroutine-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutineVersion" } +# FAKER +faker = { group = "com.github.javafaker", name = "javafaker", version.ref = "fakerVersion"} + # ASSERTK assertk = { group = "com.willowtreeapps.assertk", name = "assertk", version.ref = "assertkVersion"} From 2b44d41a284854f33ecdee18beeb9cc6bbdd0b36 Mon Sep 17 00:00:00 2001 From: LucasPrioste Date: Fri, 12 Apr 2024 17:05:20 +0100 Subject: [PATCH 13/24] - unit test (NewsRepository) - refactor code --- .../data/data_source/NewsRemoteDataSource.kt | 33 +-- .../data/repository/NewsRepositoryImp.kt | 22 +- .../data_source/NewsRemoteDataSourceTest.kt | 42 +--- .../data/repository/NewsRepositoryTest.kt | 234 ++++++++++++++++++ 4 files changed, 263 insertions(+), 68 deletions(-) create mode 100644 app/src/test/java/dev/pinkroom/marketsight/data/repository/NewsRepositoryTest.kt diff --git a/app/src/main/java/dev/pinkroom/marketsight/data/data_source/NewsRemoteDataSource.kt b/app/src/main/java/dev/pinkroom/marketsight/data/data_source/NewsRemoteDataSource.kt index 28f601d..b76e9fd 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/data/data_source/NewsRemoteDataSource.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/data/data_source/NewsRemoteDataSource.kt @@ -12,15 +12,12 @@ import dev.pinkroom.marketsight.common.SortType import dev.pinkroom.marketsight.common.toObject import dev.pinkroom.marketsight.common.verifyIfIsError import dev.pinkroom.marketsight.data.mapper.toErrorMessage -import dev.pinkroom.marketsight.data.mapper.toNewsInfo -import dev.pinkroom.marketsight.data.mapper.toNewsResponse -import dev.pinkroom.marketsight.data.mapper.toSubscriptionMessage import dev.pinkroom.marketsight.data.remote.AlpacaNewsApi import dev.pinkroom.marketsight.data.remote.AlpacaNewsService +import dev.pinkroom.marketsight.data.remote.model.dto.alpaca_news_api.NewsResponseDto import dev.pinkroom.marketsight.data.remote.model.dto.alpaca_news_service.NewsMessageDto +import dev.pinkroom.marketsight.data.remote.model.dto.alpaca_news_service.SubscriptionMessageDto import dev.pinkroom.marketsight.data.remote.model.request.MessageAlpacaService -import dev.pinkroom.marketsight.domain.model.common.SubscriptionMessage -import dev.pinkroom.marketsight.domain.model.news.NewsResponse import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.take @@ -64,16 +61,16 @@ class NewsRemoteDataSource @Inject constructor( listNews.add(news) } } - if (listNews.isNotEmpty()) emit(listNews.map { it.toNewsInfo() }) + if (listNews.isNotEmpty()) emit(listNews.toList()) } }.flowOn(dispatchers.IO) - fun sendSubscribeMessageToAlpacaService(message: MessageAlpacaService) = flow> { + fun sendSubscribeMessageToAlpacaService(message: MessageAlpacaService) = flow> { alpacaNewsService.sendMessage(message = message) alpacaNewsService.observeResponse().collect { data -> data.forEach { gson.toObject(value = it, helperIdentifier = HelperIdentifierMessagesAlpacaService.Subscription)?.let { sub -> - emit(Resource.Success(sub.toSubscriptionMessage())) + emit(Resource.Success(sub)) } ?: run { emit(Resource.Error(message = "Something went wrong on subscribe")) } @@ -86,19 +83,13 @@ class NewsRemoteDataSource @Inject constructor( limit: Int?, pageToken: String?, sort: SortType? - ): Resource{ - return try { - val response = alpacaNewsApi.getNews( - symbols = symbols?.joinToString(","), - perPage = limit, - pageToken = pageToken, - sort = sort?.type, - ) - Resource.Success(data = response.toNewsResponse()) - } catch (e: Exception){ - e.printStackTrace() - Resource.Error(message = e.message ?: "Something Went Wrong") - } + ): NewsResponseDto { + return alpacaNewsApi.getNews( + symbols = symbols?.joinToString(","), + perPage = limit, + pageToken = pageToken, + sort = sort?.type, + ) } companion object { diff --git a/app/src/main/java/dev/pinkroom/marketsight/data/repository/NewsRepositoryImp.kt b/app/src/main/java/dev/pinkroom/marketsight/data/repository/NewsRepositoryImp.kt index 4b54717..db23693 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/data/repository/NewsRepositoryImp.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/data/repository/NewsRepositoryImp.kt @@ -5,6 +5,8 @@ import dev.pinkroom.marketsight.common.DispatcherProvider import dev.pinkroom.marketsight.common.Resource import dev.pinkroom.marketsight.common.SortType import dev.pinkroom.marketsight.data.data_source.NewsRemoteDataSource +import dev.pinkroom.marketsight.data.mapper.toNewsInfo +import dev.pinkroom.marketsight.data.mapper.toNewsResponse import dev.pinkroom.marketsight.data.remote.model.request.MessageAlpacaService import dev.pinkroom.marketsight.domain.model.news.NewsResponse import dev.pinkroom.marketsight.domain.repository.NewsRepository @@ -25,7 +27,7 @@ class NewsRepositoryImp @Inject constructor( override fun getRealTimeNews() = flow { newsRemoteDataSource.getRealTimeNews().collect{ - emit(it) + emit(it.map { item -> item.toNewsInfo() }) } }.flowOn(dispatchers.IO) @@ -49,11 +51,17 @@ class NewsRepositoryImp @Inject constructor( pageToken: String?, sort: SortType? ): Resource { - return newsRemoteDataSource.getNews( - symbols = symbols, - limit = limit, - pageToken = pageToken, - sort = sort, - ) + return try { + val response = newsRemoteDataSource.getNews( + symbols = symbols, + limit = limit, + pageToken = pageToken, + sort = sort, + ) + Resource.Success(data = response.toNewsResponse()) + } catch (e: Exception){ + e.printStackTrace() + Resource.Error(message = e.message ?: "Something Went Wrong") + } } } \ No newline at end of file diff --git a/app/src/test/java/dev/pinkroom/marketsight/data/data_source/NewsRemoteDataSourceTest.kt b/app/src/test/java/dev/pinkroom/marketsight/data/data_source/NewsRemoteDataSourceTest.kt index 7a1e206..4ff5fd8 100644 --- a/app/src/test/java/dev/pinkroom/marketsight/data/data_source/NewsRemoteDataSourceTest.kt +++ b/app/src/test/java/dev/pinkroom/marketsight/data/data_source/NewsRemoteDataSourceTest.kt @@ -91,7 +91,7 @@ class NewsRemoteDataSourceTest { } @Test - fun `When receive message on getRealTimeNews, then try to parse Json to NewsInfo class`() = runTest { + fun `When receive message on getRealTimeNews, then try to parse Json to NewsInfoDto class`() = runTest { // GIVEN val listNews = newsFactory.buildList() mockNewsServiceWithSuccess(newsList = listNews) @@ -137,34 +137,7 @@ class NewsRemoteDataSourceTest { sort = sort.type, ) } - assertThat(response is Resource.Success).isTrue() - val data = response as Resource.Success - assertThat(data.data.news.size).isEqualTo(limit) - } - - @Test - fun `Given params, then alpaca api throw error on call getNews`() = runTest { - // GIVEN - mockNewsResponseApiWithError() - - // WHEN - val response = alpacaRemoteDataSource.getNews( - symbols = null, - limit = null, - pageToken = null, - sort = null - ) - - // THEN - coVerify { - alpacaNewsApi.getNews( - symbols = any(), - pageToken = any(), - sort = any(), - perPage = any(), - ) - } - assertThat(response is Resource.Error).isTrue() + assertThat(response.news.size).isEqualTo(limit) } @Test @@ -194,17 +167,6 @@ class NewsRemoteDataSourceTest { assertThat(response is Resource.Error).isTrue() } - private fun mockNewsResponseApiWithError() { - coEvery { alpacaNewsApi.getNews( - symbols = any(), - perPage = any(), - pageToken = any(), - sort = any(), - ) }.throws( - Exception("Test Error") - ) - } - private fun mockNewsResponseApiWithSuccess(limit: Int) { coEvery { alpacaNewsApi.getNews( symbols = any(), diff --git a/app/src/test/java/dev/pinkroom/marketsight/data/repository/NewsRepositoryTest.kt b/app/src/test/java/dev/pinkroom/marketsight/data/repository/NewsRepositoryTest.kt new file mode 100644 index 0000000..7f743ad --- /dev/null +++ b/app/src/test/java/dev/pinkroom/marketsight/data/repository/NewsRepositoryTest.kt @@ -0,0 +1,234 @@ +package dev.pinkroom.marketsight.data.repository + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isNotEmpty +import assertk.assertions.isTrue +import com.github.javafaker.Faker +import com.tinder.scarlet.WebSocket +import dev.pinkroom.marketsight.common.ActionAlpaca +import dev.pinkroom.marketsight.common.Resource +import dev.pinkroom.marketsight.common.SortType +import dev.pinkroom.marketsight.data.data_source.NewsRemoteDataSource +import dev.pinkroom.marketsight.data.mapper.toNewsInfo +import dev.pinkroom.marketsight.data.remote.model.dto.alpaca_news_api.NewsResponseDto +import dev.pinkroom.marketsight.data.remote.model.dto.alpaca_news_service.NewsMessageDto +import dev.pinkroom.marketsight.data.remote.model.dto.alpaca_news_service.SubscriptionMessageDto +import dev.pinkroom.marketsight.data.remote.model.request.MessageAlpacaService +import dev.pinkroom.marketsight.factories.NewsDtoFactory +import dev.pinkroom.marketsight.factories.NewsFactory +import dev.pinkroom.marketsight.util.MainCoroutineRule +import dev.pinkroom.marketsight.util.TestDispatcherProvider +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +@ExperimentalCoroutinesApi +class NewsRepositoryTest{ + + @get:Rule + val coroutineRule = MainCoroutineRule() + + private val faker = Faker() + private val newsFactory = NewsFactory() + private val newsDtoFactory = NewsDtoFactory() + private val newsRemoteDataSource = mockk(relaxed = true, relaxUnitFun = true) + private val dispatchers = TestDispatcherProvider() + private val newsRepository = NewsRepositoryImp( + newsRemoteDataSource = newsRemoteDataSource, + dispatchers = dispatchers, + ) + + @Test + fun `Given params, when init subscribe news, then subscribeNews is called with correct params and emit values`() = runTest { + // GIVEN + mockSubscribeNewsWithSuccess() + + // WHEN + val response = newsRepository.subscribeNews().first() + + // THEN + assertThat(response is Resource.Success).isTrue() + } + + @Test + fun `When receive message on getRealTimeNews, then emit NewsInfo class`() = runTest { + // GIVEN + val listNews = newsFactory.buildList() + mockNewsServiceWithSuccess(newsList = listNews) + + // WHEN + val response = newsRepository.getRealTimeNews().first() + + // THEN + verify { newsRemoteDataSource.getRealTimeNews() } + val expectedResult = listNews.map { it.toNewsInfo() } + assertThat(response).isNotEmpty() + assertThat(response).isEqualTo(expectedResult) + } + + @Test + fun `Given params, then alpaca api call getNews is called with correct params`() = runTest { + // GIVEN + val symbols = listOf("*") + val limit = 15 + val pageToken = null + val sort = SortType.DESC + mockNewsResponseApiWithSuccess(limit) + + // WHEN + val response = newsRepository.getNews( + symbols = symbols, + limit = limit, + pageToken = pageToken, + sort = sort + ) + + // THEN + coVerify { + newsRemoteDataSource.getNews( + symbols = symbols, + limit = limit, + pageToken = pageToken, + sort = sort, + ) + } + assertThat(response is Resource.Success).isTrue() + val data = response as Resource.Success + assertThat(data.data.news.size).isEqualTo(limit) + } + + @Test + fun `Given params, then alpaca api throw error on call getNews`() = runTest { + // GIVEN + mockNewsResponseApiWithError() + + // WHEN + val response = newsRepository.getNews( + symbols = null, + limit = null, + pageToken = null, + sort = null + ) + + // THEN + coVerify { + newsRemoteDataSource.getNews( + symbols = any(), + pageToken = any(), + sort = any(), + limit = any(), + ) + } + assertThat(response is Resource.Error).isTrue() + } + + @Test + fun `Given params, when change filters, then receive list with symbols subscribed`() = runTest { + // GIVEN + val messageToSend = MessageAlpacaService(action = ActionAlpaca.Subscribe.action, news = listOf("TSLA","AAPL")) + mockMessageSubscriptionServiceWithSuccess(messageToSend) + + // WHEN + val response = newsRepository.changeFilterNews(symbols = messageToSend.news!!, actionAlpaca = ActionAlpaca.Subscribe) + + // THEN + verify { newsRemoteDataSource.sendSubscribeMessageToAlpacaService(message = messageToSend) } + assertThat(response is Resource.Success).isTrue() + val data = response as Resource.Success + assertThat(data.data).isEqualTo(messageToSend.news) + } + + @Test + fun `Given params, when change filters, then error occur and return list with symbols that fail to subscribe`() = runTest { + // GIVEN + val messageToSend = MessageAlpacaService(action = ActionAlpaca.Subscribe.action, news = listOf("TSLA","AAPL")) + mockMessageSubscriptionServiceWithError() + + // WHEN + val response = newsRepository.changeFilterNews(symbols = messageToSend.news!!, actionAlpaca = ActionAlpaca.Subscribe) + + // THEN + verify { newsRemoteDataSource.sendSubscribeMessageToAlpacaService(message = messageToSend) } + assertThat(response is Resource.Error).isTrue() + val data = response as Resource.Error + assertThat(data.data).isEqualTo(messageToSend.news) + } + + + private fun mockNewsResponseApiWithSuccess(limit: Int) { + coEvery { + newsRemoteDataSource.getNews( + symbols = any(), + limit = any(), + pageToken = any(), + sort = any(), + ) + }.returns( + NewsResponseDto( + news = newsDtoFactory.buildList(number = limit), + nextPageToken = faker.lorem().word() + ) + ) + } + + private fun mockNewsResponseApiWithError() { + coEvery { + newsRemoteDataSource.getNews( + symbols = any(), + limit = any(), + pageToken = any(), + sort = any(), + ) + }.throws( + Exception("Test Error") + ) + } + + private fun mockNewsServiceWithSuccess(newsList: List) { + every { newsRemoteDataSource.getRealTimeNews() }.returns( + flow { + emit(newsList) + } + ) + } + + private fun mockSubscribeNewsWithSuccess(){ + every { newsRemoteDataSource.subscribeNews(any()) }.returns( + flow { + emit(Resource.Success(data = WebSocket.Event.OnConnectionOpened(webSocket = Any()))) + } + ) + } + + private fun mockMessageSubscriptionServiceWithSuccess(messageAlpacaService: MessageAlpacaService) { + val returnedMessageService = SubscriptionMessageDto( + type = "subscription", + news = messageAlpacaService.news, + quotes = messageAlpacaService.quotes, + trades = messageAlpacaService.trades, + ) + + every { newsRemoteDataSource.sendSubscribeMessageToAlpacaService(any()) } returns( + flow { + emit(Resource.Success(data = returnedMessageService)) + } + ) + } + + private fun mockMessageSubscriptionServiceWithError() { + every { newsRemoteDataSource.sendSubscribeMessageToAlpacaService(any()) } returns( + flow { + emit(Resource.Error()) + } + ) + } +} \ No newline at end of file From 734e555772d2a0baa1308f0b9d28fba8cf55d9f0 Mon Sep 17 00:00:00 2001 From: LucasPrioste Date: Fri, 12 Apr 2024 17:45:31 +0100 Subject: [PATCH 14/24] - unit test (ChangeFilterNews UseCase) - refactor code --- .../use_case/news/ChangeFilterNewsTest.kt | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 app/src/test/java/dev/pinkroom/marketsight/domain/use_case/news/ChangeFilterNewsTest.kt diff --git a/app/src/test/java/dev/pinkroom/marketsight/domain/use_case/news/ChangeFilterNewsTest.kt b/app/src/test/java/dev/pinkroom/marketsight/domain/use_case/news/ChangeFilterNewsTest.kt new file mode 100644 index 0000000..0ab95d6 --- /dev/null +++ b/app/src/test/java/dev/pinkroom/marketsight/domain/use_case/news/ChangeFilterNewsTest.kt @@ -0,0 +1,122 @@ +package dev.pinkroom.marketsight.domain.use_case.news + +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isNotEmpty +import assertk.assertions.isTrue +import dev.pinkroom.marketsight.common.ActionAlpaca +import dev.pinkroom.marketsight.common.Resource +import dev.pinkroom.marketsight.domain.repository.NewsRepository +import dev.pinkroom.marketsight.util.MainCoroutineRule +import dev.pinkroom.marketsight.util.TestDispatcherProvider +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.last +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +@ExperimentalCoroutinesApi +class ChangeFilterNewsTest{ + + @get:Rule + val coroutineRule = MainCoroutineRule() + + private val dispatchers = TestDispatcherProvider() + private val newsRepository = mockk(relaxed = true, relaxUnitFun = true) + private val changeFilterNews = ChangeFilterNews( + newsRepository = newsRepository, + dispatchers = dispatchers, + ) + + @Test + fun `Given params, then call changeFilterNews and return list with subscribed symbols`() = runTest { + // GIVEN + val subscribeSymbols = listOf("TSLA","AAPL") + val unsubscribeSymbols = listOf("AAA") + mockChangeFilterNewsSuccess(subscribeSymbols) + + // WHEN + val response = changeFilterNews( + subscribeSymbols = subscribeSymbols, + unsubscribeSymbols = unsubscribeSymbols + ).toList() + + // THEN + coVerify { + newsRepository.changeFilterNews( + symbols = unsubscribeSymbols, + actionAlpaca = ActionAlpaca.Unsubscribe, + ) + } + coVerify { + newsRepository.changeFilterNews( + symbols = subscribeSymbols, + actionAlpaca = ActionAlpaca.Subscribe, + ) + } + assertThat(response).isNotEmpty() + response.forEachIndexed { index, it -> + assertThat(it is Resource.Success).isTrue() + val data = it as Resource.Success + val expectedResult = if (index == response.size) emptyList() + else subscribeSymbols + assertThat(data.data).isEqualTo(expectedResult) + } + } + + @Test + fun `Given params, then call changeFilterNews and return error`() = runTest { + // GIVEN + val subscribeSymbols = listOf("TSLA","AAPL") + val unsubscribeSymbols = listOf("AAA") + mockChangeFilterNewsError( + subscribedSymbols = subscribeSymbols, + unsubscribedSymbols = unsubscribeSymbols + ) + + // WHEN + val response = changeFilterNews( + subscribeSymbols = subscribeSymbols, + unsubscribeSymbols = unsubscribeSymbols + ).last() + + // THEN + coVerify { + newsRepository.changeFilterNews( + symbols = unsubscribeSymbols, + actionAlpaca = ActionAlpaca.Unsubscribe, + ) + } + coVerify { + newsRepository.changeFilterNews( + symbols = subscribeSymbols, + actionAlpaca = ActionAlpaca.Subscribe, + ) + } + assertThat(response is Resource.Error).isTrue() + val data = response as Resource.Error + val expectedResult = unsubscribeSymbols + subscribeSymbols + assertThat(data.data).isEqualTo(expectedResult) + } + + private fun mockChangeFilterNewsSuccess( + subscribedSymbols: List, + ){ + coEvery { newsRepository.changeFilterNews(any(),any()) }.returnsMany( + Resource.Success(data = emptyList()), Resource.Success(data = subscribedSymbols) + ) + } + + private fun mockChangeFilterNewsError( + subscribedSymbols: List, + unsubscribedSymbols: List, + ){ + coEvery { newsRepository.changeFilterNews(any(),any()) }.returns( + Resource.Error(data = subscribedSymbols + unsubscribedSymbols) + ) + } +} \ No newline at end of file From e487af320d6f72ac5cb475fbbbc05c874a7d31fc Mon Sep 17 00:00:00 2001 From: LucasPrioste Date: Wed, 17 Apr 2024 17:54:59 +0100 Subject: [PATCH 15/24] - main carousel news --- app/build.gradle.kts | 4 + .../pinkroom/marketsight/MarketSightApp.kt | 12 +- .../domain/model/common/SubInfoSymbols.kt | 7 + .../domain/model/news/ImageSize.kt | 6 + .../marketsight/domain/model/news/NewsInfo.kt | 18 +- .../pinkroom/marketsight/ui/MainActivity.kt | 2 +- .../ui/core/navigation/NavigationAppHost.kt | 11 +- .../ui/core/navigation/NavigationBottomBar.kt | 2 +- .../marketsight/ui/core/theme/Color.kt | 4 +- .../marketsight/ui/core/theme/Dimensions.kt | 22 ++- .../ui/core/theme/ShimmerEffect.kt | 45 +++++ .../marketsight/ui/news_screen/NewsScreen.kt | 105 +++++++++++- .../marketsight/ui/news_screen/NewsUiState.kt | 49 ++++++ .../ui/news_screen/NewsViewModel.kt | 26 +++ .../components/IndicatorPageCarousel.kt | 62 +++++++ .../ui/news_screen/components/MainNews.kt | 44 +++++ .../ui/news_screen/components/MainNewsCard.kt | 162 ++++++++++++++++++ .../main/res/drawable/default_news_large.jpeg | Bin 0 -> 153161 bytes .../main/res/drawable/default_news_small.jpeg | Bin 0 -> 53466 bytes .../main/res/drawable/default_news_thumb.jpeg | Bin 0 -> 6743 bytes gradle/libs.versions.toml | 5 + 21 files changed, 568 insertions(+), 18 deletions(-) create mode 100644 app/src/main/java/dev/pinkroom/marketsight/domain/model/common/SubInfoSymbols.kt create mode 100644 app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/ShimmerEffect.kt create mode 100644 app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsUiState.kt create mode 100644 app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsViewModel.kt create mode 100644 app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/IndicatorPageCarousel.kt create mode 100644 app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/MainNews.kt create mode 100644 app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/MainNewsCard.kt create mode 100644 app/src/main/res/drawable/default_news_large.jpeg create mode 100644 app/src/main/res/drawable/default_news_small.jpeg create mode 100644 app/src/main/res/drawable/default_news_thumb.jpeg diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ecc4305..a2d2aa1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -75,6 +75,7 @@ dependencies { coreLibraryDesugaring(libs.androidx.desugar) implementation(libs.androidx.core.ktx) implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.ui) implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.tooling.preview) @@ -108,6 +109,9 @@ dependencies { // SPLASH implementation(libs.splash) + // COIL + implementation(libs.coil) + // JSON implementation(libs.json) diff --git a/app/src/main/java/dev/pinkroom/marketsight/MarketSightApp.kt b/app/src/main/java/dev/pinkroom/marketsight/MarketSightApp.kt index cf87006..e4d710d 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/MarketSightApp.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/MarketSightApp.kt @@ -1,7 +1,17 @@ package dev.pinkroom.marketsight import android.app.Application +import coil.ImageLoader +import coil.ImageLoaderFactory +import coil.util.DebugLogger import dagger.hilt.android.HiltAndroidApp @HiltAndroidApp -class MarketSightApp: Application() \ No newline at end of file +class MarketSightApp: Application(), ImageLoaderFactory{ + override fun newImageLoader(): ImageLoader { + return ImageLoader(this).newBuilder() + .logger(DebugLogger()) + .build() + } + +} \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/domain/model/common/SubInfoSymbols.kt b/app/src/main/java/dev/pinkroom/marketsight/domain/model/common/SubInfoSymbols.kt new file mode 100644 index 0000000..a97d12e --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/domain/model/common/SubInfoSymbols.kt @@ -0,0 +1,7 @@ +package dev.pinkroom.marketsight.domain.model.common + +data class SubInfoSymbols( + val name: String, + val symbol: String, + val isSubscribed: Boolean = false, +) diff --git a/app/src/main/java/dev/pinkroom/marketsight/domain/model/news/ImageSize.kt b/app/src/main/java/dev/pinkroom/marketsight/domain/model/news/ImageSize.kt index cdba1e7..e0ee24a 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/domain/model/news/ImageSize.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/domain/model/news/ImageSize.kt @@ -4,4 +4,10 @@ sealed interface ImageSize { data object Large: ImageSize data object Small: ImageSize data object Thumb: ImageSize +} + +fun ImageSize.getAspectRatio() = when(this){ + ImageSize.Large -> 2024f / 1536f + ImageSize.Small -> 1024f / 768f + ImageSize.Thumb -> 250f / 187f } \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/domain/model/news/NewsInfo.kt b/app/src/main/java/dev/pinkroom/marketsight/domain/model/news/NewsInfo.kt index db4182b..cd40d36 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/domain/model/news/NewsInfo.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/domain/model/news/NewsInfo.kt @@ -1,6 +1,11 @@ package dev.pinkroom.marketsight.domain.model.news import java.time.LocalDateTime +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.util.Locale + data class NewsInfo( val id: Long, @@ -13,4 +18,15 @@ data class NewsInfo( val symbols: List, val source: String, val images: List? = null, -) +){ + fun getImageUrl(imageSize: ImageSize) = images?.find { it.size == imageSize }?.url + + fun getUpdatedDateFormatted(): String{ + val formatter = DateTimeFormatter + .ofLocalizedDateTime(FormatStyle.LONG, FormatStyle.SHORT) + .withLocale(Locale.getDefault()) + .withZone(ZoneId.systemDefault()) + + return updatedAt.format(formatter) + } +} diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/MainActivity.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/MainActivity.kt index 16fefa9..6bd6322 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/MainActivity.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/MainActivity.kt @@ -24,7 +24,7 @@ class MainActivity : ComponentActivity() { setContent { val navController = rememberNavController() - val startDestination = Route.HomeScreen + val startDestination = Route.NewsScreen MarketSightTheme { Surface( diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/NavigationAppHost.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/NavigationAppHost.kt index bc34185..68a7d28 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/NavigationAppHost.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/NavigationAppHost.kt @@ -3,6 +3,7 @@ package dev.pinkroom.marketsight.ui.core.navigation import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController import androidx.navigation.NavType import androidx.navigation.compose.NavHost @@ -13,6 +14,7 @@ import dev.pinkroom.marketsight.ui.detail_screen.DetailScreen import dev.pinkroom.marketsight.ui.home_screen.HomeScreen import dev.pinkroom.marketsight.ui.home_screen.HomeViewModel import dev.pinkroom.marketsight.ui.news_screen.NewsScreen +import dev.pinkroom.marketsight.ui.news_screen.NewsViewModel @Composable fun NavigationAppHost( @@ -34,7 +36,14 @@ fun NavigationAppHost( composable( route = Route.NewsScreen.route, ) { - NewsScreen() + val viewModel = hiltViewModel() + val uiState = viewModel.uiState.collectAsStateWithLifecycle().value + + NewsScreen( + news = uiState.news, + realTimeNews = uiState.realTimeNews, + symbols = uiState.symbols, + ) } composable( route = Route.DetailScreen.route, diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/NavigationBottomBar.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/NavigationBottomBar.kt index 0359eb5..e73dc8d 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/NavigationBottomBar.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/NavigationBottomBar.kt @@ -34,7 +34,7 @@ import dev.pinkroom.marketsight.ui.core.theme.dimens @Composable fun NavigationBottomBar( navController: NavHostController, - startDestination: Route.HomeScreen, + startDestination: Route, ){ val items = listOf( BottomBarItem.Home, diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Color.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Color.kt index 3d49884..5d3b9c0 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Color.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Color.kt @@ -10,4 +10,6 @@ val Gray = Color(0xFF8A8A94) val Manatee = Color(0xFF92929C) val Green = Color(0xFF3DD787) val Purple = Color(0xFF7F55E7) -val Red = Color(0xFFE94237) \ No newline at end of file +val Red = Color(0xFFE94237) +val PhilippineSilver = Color(0xFFB8B5B5) +val PhilippineGray = Color(0xFF8F8B8B) \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Dimensions.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Dimensions.kt index 2b30d7a..3b44de8 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Dimensions.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Dimensions.kt @@ -6,6 +6,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import dev.pinkroom.marketsight.domain.model.news.ImageSize class Dimensions( // Text @@ -38,8 +39,8 @@ class Dimensions( // Elevation val lowElevation: Dp = 2.dp, - val normalElevation: Dp = 5.dp, - val largeElevation: Dp = 8.dp, + val normalElevation: Dp = 9.dp, + val largeElevation: Dp = 12.dp, // Shape val smallShape: Dp = 10.dp, @@ -48,9 +49,14 @@ class Dimensions( // Page Padding val horizontalPadding: Dp = 25.dp, + val contentTopPadding: Dp = 10.dp, + val contentBottomPadding: Dp = 40.dp, // Others val menuBottomPadding: Dp = 15.dp, + val imageSizeMainNews: ImageSize = ImageSize.Small, + val circlePageIndicatorSize: Dp = 9.dp, + val spaceBetweenPageIndicator: Dp = 2.dp, ) val dimens: Dimensions @@ -64,6 +70,12 @@ val dimens: Dimensions } // Here you will override the dimensions needed depending on the screen size -private val smallDimensions = Dimensions() -private val normalDimensions = Dimensions() -private val largeDimensions = Dimensions() \ No newline at end of file +private val smallDimensions = Dimensions( + imageSizeMainNews = ImageSize.Small, +) +private val normalDimensions = Dimensions( + imageSizeMainNews = ImageSize.Small, +) +private val largeDimensions = Dimensions( + imageSizeMainNews = ImageSize.Large, +) \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/ShimmerEffect.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/ShimmerEffect.kt new file mode 100644 index 0000000..7ca4efc --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/ShimmerEffect.kt @@ -0,0 +1,45 @@ +package dev.pinkroom.marketsight.ui.core.theme + +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.foundation.background +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.unit.IntSize + +fun Modifier.shimmerEffect(): Modifier = composed { + var size by remember { + mutableStateOf(IntSize.Zero) + } + val transition = rememberInfiniteTransition(label = "Transition Shimmer Effect") + val startOffsetX by transition.animateFloat( + initialValue = -2 * size.width.toFloat(), + targetValue = 2 * size.width.toFloat(), + animationSpec = infiniteRepeatable( + animation = tween(1300) + ), label = "Shimmer Effect" + ) + + background( + brush = Brush.linearGradient( + colors = listOf( + PhilippineSilver, + PhilippineGray, + PhilippineSilver, + ), + start = Offset(startOffsetX, 0f), + end = Offset(startOffsetX + size.width.toFloat(), size.height.toFloat()) + ) + ).onGloballyPositioned { + size = it.size + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsScreen.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsScreen.kt index 73c0698..e326004 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsScreen.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsScreen.kt @@ -1,23 +1,55 @@ package dev.pinkroom.marketsight.ui.news_screen -import androidx.compose.foundation.layout.Column +import android.content.Intent +import android.net.Uri +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.material3.Text +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview +import dev.pinkroom.marketsight.domain.model.common.SubInfoSymbols +import dev.pinkroom.marketsight.domain.model.news.ImageSize +import dev.pinkroom.marketsight.domain.model.news.ImagesNews +import dev.pinkroom.marketsight.domain.model.news.NewsInfo +import dev.pinkroom.marketsight.ui.core.theme.dimens +import dev.pinkroom.marketsight.ui.news_screen.components.MainNews +import java.time.LocalDateTime @Composable fun NewsScreen( modifier: Modifier = Modifier, + news: List, + realTimeNews: List, + symbols: List, ){ - Column( + val context = LocalContext.current + + LazyColumn( modifier = modifier .fillMaxSize(), - ) { - Text( - text = "News Screen", + contentPadding = PaddingValues( + top = dimens.contentTopPadding, + bottom = dimens.contentBottomPadding, ) + ) { + item { + MainNews( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = dimens.horizontalPadding, + ), + newsList = news, + onNewsClick = { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(it.url)) + context.startActivity(intent) + } + ) + } } } @@ -27,5 +59,64 @@ fun NewsScreen( ) @Composable fun NewsScreenPreview(){ - NewsScreen() + NewsScreen( + news = listOf( + NewsInfo( + id = 1L, + symbols = listOf("TSLA","AAPL","BTC"), + images = listOf( + ImagesNews( + size = ImageSize.Small, + url = "https://cdn.benzinga.com/files/imagecache/1024x768xUP/market-clubhouse-morning-memo_201.png", + ) + ), + source = "Bezinga", + url = "https://www.benzinga.com/", + updatedAt = LocalDateTime.now(), + createdAt = LocalDateTime.now(), + author = "RIPS", + summary = "Good Morning Traders! In today's Market Clubhouse Morning Memo, we will discuss SPY, QQQ, AAPL, MSFT, NVDA, GOOGL, META, and TSLA.", + headline = "Market Clubhouse Morning Memo - April 11th, 2024 (Trade Strategy For SPY, QQQ, AAPL, MSFT, NVDA, GOOGL, META, And TSLA)", + ), + NewsInfo( + id = 1L, + symbols = listOf("TSLA","AAPL","BTC"), + images = listOf( + ImagesNews( + size = ImageSize.Small, + url = "https://cdn.benzinga.com/files/imagecache/1024x768xUP/market-clubhouse-morning-memo_201.png", + ) + ), + source = "Bezinga", + url = "https://www.benzinga.com/", + updatedAt = LocalDateTime.now(), + createdAt = LocalDateTime.now(), + author = "RIPS", + summary = "Good Morning Traders! In today's Market Clubhouse Morning Memo, we will discuss SPY, QQQ, AAPL, MSFT, NVDA, GOOGL, META, and TSLA.", + headline = "Market Clubhouse Morning Memo - April 11th, 2024 (Trade Strategy For SPY, QQQ, AAPL, MSFT, NVDA, GOOGL, META, And TSLA)", + ), + ), + realTimeNews = listOf( + NewsInfo( + id = 1L, + symbols = listOf("TSLA","AAPL","BTC"), + images = null, + source = "Bezinga", + url = "https://www.benzinga.com/", + updatedAt = LocalDateTime.now(), + createdAt = LocalDateTime.now(), + author = "RIPS", + summary = "Good Morning Traders! In today's Market Clubhouse Morning Memo, we will discuss SPY, QQQ, AAPL, MSFT, NVDA, GOOGL, META, and TSLA.", + headline = "Market Clubhouse Morning Memo - April 11th, 2024 (Trade Strategy For SPY, QQQ, AAPL, MSFT, NVDA, GOOGL, META, And TSLA)", + ), + ), + symbols = listOf( + SubInfoSymbols( + name = "TESLA", symbol = "TSLA", + ), + SubInfoSymbols( + name = "APPLE", symbol = "AAPL", + ), + ), + ) } \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsUiState.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsUiState.kt new file mode 100644 index 0000000..4ecb74c --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsUiState.kt @@ -0,0 +1,49 @@ +package dev.pinkroom.marketsight.ui.news_screen + +import dev.pinkroom.marketsight.domain.model.common.SubInfoSymbols +import dev.pinkroom.marketsight.domain.model.news.ImageSize +import dev.pinkroom.marketsight.domain.model.news.ImagesNews +import dev.pinkroom.marketsight.domain.model.news.NewsInfo +import java.time.LocalDateTime + +data class NewsUiState( + val isLoading: Boolean = false, + val news: List = listOf( + NewsInfo( + id = 1L, + symbols = listOf("TSLA","AAPL","BTC"), + images = listOf( + ImagesNews( + size = ImageSize.Small, + url = "https://cdn.benzinga.com/files/imagecache/1024x768xUP/market-clubhouse-morning-memo_201.png", + ) + ), + source = "Bezinga", + url = "https://www.benzinga.com/", + updatedAt = LocalDateTime.now(), + createdAt = LocalDateTime.now(), + author = "RIPS", + summary = "Good Morning Traders! In today's Market Clubhouse Morning Memo, we will discuss SPY, QQQ, AAPL, MSFT, NVDA, GOOGL, META, and TSLA.", + headline = "Market Clubhouse Morning Memo - April 11th, 2024 (Trade Strategy For SPY, QQQ, AAPL, MSFT, NVDA, GOOGL, META, And TSLA)", + ), + NewsInfo( + id = 2L, + symbols = listOf("TSLA","AAPL","BTC"), + images = listOf( + ImagesNews( + size = ImageSize.Small, + url = "https://cdn.benzinga.com/files/imagecache/1024x768xUP/market-clubhouse-morning-memo_201.p", + ) + ), + source = "Bezinga", + url = "https://www.benzinga.com/", + updatedAt = LocalDateTime.now(), + createdAt = LocalDateTime.now(), + author = "RIPS", + summary = "Good Morning Traders! In today's Market Clubhouse Morning Memo, we will discuss SPY, QQQ, AAPL, MSFT, NVDA, GOOGL, META, and TSLA.", + headline = "Market Clubhouse Morning Memo - April 11th, 2024 (Trade Strategy For SPY, QQQ, AAPL, MSFT, NVDA, GOOGL, META, And TSLA)", + ), + ), + val realTimeNews: List = listOf(), + val symbols: List = listOf(), +) diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsViewModel.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsViewModel.kt new file mode 100644 index 0000000..4cec0a4 --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsViewModel.kt @@ -0,0 +1,26 @@ +package dev.pinkroom.marketsight.ui.news_screen + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import dev.pinkroom.marketsight.domain.use_case.news.ChangeFilterNews +import dev.pinkroom.marketsight.domain.use_case.news.GetNews +import dev.pinkroom.marketsight.domain.use_case.news.GetRealTimeNews +import dev.pinkroom.marketsight.domain.use_case.news.SubscribeNews +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import javax.inject.Inject + +@HiltViewModel +class NewsViewModel @Inject constructor( + private val subscribeNews: SubscribeNews, + private val getRealTimeNews: GetRealTimeNews, + private val getNews: GetNews, + private val changeFilterNews: ChangeFilterNews, +): ViewModel() { + private val _uiState = MutableStateFlow(NewsUiState()) + val uiState = _uiState.asStateFlow() + + init { + + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/IndicatorPageCarousel.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/IndicatorPageCarousel.kt new file mode 100644 index 0000000..85e96cb --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/IndicatorPageCarousel.kt @@ -0,0 +1,62 @@ +package dev.pinkroom.marketsight.ui.news_screen.components + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import dev.pinkroom.marketsight.ui.core.theme.PhilippineGray +import dev.pinkroom.marketsight.ui.core.theme.PhilippineSilver +import dev.pinkroom.marketsight.ui.core.theme.dimens +import kotlinx.coroutines.launch + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun IndicatorPageCarousel( + pagerState: PagerState, +){ + val scope = rememberCoroutineScope() + val interactionSource = remember { MutableInteractionSource() } + Row( + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + repeat(pagerState.pageCount) { iteration -> + val selectedColor = if (isSystemInDarkTheme()) PhilippineSilver else PhilippineGray + val unSelectedColor = if (isSystemInDarkTheme()) PhilippineGray else PhilippineSilver + val color = if (pagerState.currentPage == iteration) selectedColor else unSelectedColor + + Box( + modifier = Modifier + .padding(horizontal = dimens.spaceBetweenPageIndicator) + .clip(CircleShape) + .background(color) + .size(dimens.circlePageIndicatorSize) + .clickable( + indication = null, + interactionSource = interactionSource, + onClick = { + if (pagerState.currentPage != iteration) + scope.launch { + pagerState.animateScrollToPage(page = iteration) + } + } + ), + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/MainNews.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/MainNews.kt new file mode 100644 index 0000000..cdbff8a --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/MainNews.kt @@ -0,0 +1,44 @@ +package dev.pinkroom.marketsight.ui.news_screen.components + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import dev.pinkroom.marketsight.domain.model.news.NewsInfo +import dev.pinkroom.marketsight.ui.core.theme.dimens + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun MainNews( + modifier: Modifier = Modifier, + newsList: List, + onNewsClick: (news: NewsInfo) -> Unit, +){ + val pagerState = rememberPagerState( + pageCount = { newsList.size } + ) + + Column( + modifier = Modifier + .fillMaxWidth(), + ) { + HorizontalPager( + state = pagerState, + key = { newsList[it].id } + ) { index -> + val news = newsList[index] + MainNewsCard( + modifier = modifier, + news = news, + onClick = onNewsClick + ) + } + Spacer(modifier = Modifier.height(dimens.smallPadding)) + IndicatorPageCarousel(pagerState = pagerState) + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/MainNewsCard.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/MainNewsCard.kt new file mode 100644 index 0000000..b4b438b --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/MainNewsCard.kt @@ -0,0 +1,162 @@ +package dev.pinkroom.marketsight.ui.news_screen.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.IntSize +import coil.compose.SubcomposeAsyncImage +import dev.pinkroom.marketsight.R +import dev.pinkroom.marketsight.domain.model.news.ImageSize +import dev.pinkroom.marketsight.domain.model.news.ImagesNews +import dev.pinkroom.marketsight.domain.model.news.NewsInfo +import dev.pinkroom.marketsight.domain.model.news.getAspectRatio +import dev.pinkroom.marketsight.ui.core.theme.Black +import dev.pinkroom.marketsight.ui.core.theme.White +import dev.pinkroom.marketsight.ui.core.theme.dimens +import dev.pinkroom.marketsight.ui.core.theme.shimmerEffect +import java.time.LocalDateTime + +@Composable +fun MainNewsCard( + modifier: Modifier = Modifier, + news: NewsInfo, + onClick: (news: NewsInfo) -> Unit +){ + var size by remember { mutableStateOf(IntSize.Zero) } + Box( + modifier = modifier + .fillMaxWidth() + .shadow( + elevation = dimens.normalElevation, + shape = RoundedCornerShape(size = dimens.normalShape) + ) + .aspectRatio(ratio = dimens.imageSizeMainNews.getAspectRatio()) + .onSizeChanged { + size = it + } + .clickable( + onClick = { onClick(news) } + ), + ) { + SubcomposeAsyncImage( + model = news.getImageUrl(imageSize = dimens.imageSizeMainNews), + contentDescription = null, + error = { + val imageToLoad = when (dimens.imageSizeMainNews) { + ImageSize.Large -> R.drawable.default_news_large + ImageSize.Small -> R.drawable.default_news_small + ImageSize.Thumb -> R.drawable.default_news_thumb + } + Image( + painter = painterResource(id = imageToLoad), + contentDescription = null, + modifier = Modifier + .fillMaxSize() + ) + }, + loading = { + Box( + modifier = Modifier + .fillMaxSize() + .shimmerEffect() + ) + }, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + ) + Box( + modifier = Modifier + .fillMaxSize() + .background( + Brush.verticalGradient( + colors = listOf( + Color.Transparent, + Black, + ), + startY = 0f, + endY = size.height.toFloat() / 1.35f + ) + ) + ){ + Column( + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(horizontal = dimens.normalPadding) + .padding(bottom = dimens.normalPadding), + verticalArrangement = Arrangement.spacedBy(dimens.xSmallPadding) + ) { + Text( + text = news.headline, + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Medium, + color = White, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Text( + modifier = Modifier.fillMaxWidth(), + text = news.getUpdatedDateFormatted(), + fontWeight = FontWeight.Bold, + color = White, + textAlign = TextAlign.Start, + ) + } + } + } +} + +@Preview( + showBackground = true, + showSystemUi = true, +) +@Composable +fun MainNewsCardPreview(){ + MainNewsCard( + news = NewsInfo( + id = 1L, + symbols = listOf("TSLA","AAPL","BTC"), + images = listOf( + ImagesNews( + size = ImageSize.Small, + url = "https://cdn.benzinga.com/files/imagecache/1024x768xUP/market-clubhouse-morning-memo_201.png", + ) + ), + source = "Bezinga", + url = "https://www.benzinga.com/", + updatedAt = LocalDateTime.now(), + createdAt = LocalDateTime.now(), + author = "RIPS", + summary = "Good Morning Traders! In today's Market Clubhouse Morning Memo, we will discuss SPY, QQQ, AAPL, MSFT, NVDA, GOOGL, META, and TSLA.", + headline = "Market Clubhouse Morning Memo - April 11th, 2024 (Trade Strategy For SPY, QQQ, AAPL, MSFT, NVDA, GOOGL, META, And TSLA)", + ), + onClick = {} + ) +} \ No newline at end of file diff --git a/app/src/main/res/drawable/default_news_large.jpeg b/app/src/main/res/drawable/default_news_large.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..7955eb28fcf7ddab357c8d092e8b895480c8a2bf GIT binary patch literal 153161 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𝔪z0I=w>4ZS3(j;CYnT+vY_#GCO#Z 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�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
JiC@v||kLq5Rze6qfXB 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 Df62mr literal 0 HcmV?d00001 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9f7d112..156f73b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,12 +20,14 @@ mockkVersion = "1.13.10" coroutineVersion = "1.8.0" assertkVersion = "0.28.0" fakerVersion = "1.0.2" +coilVersion = "2.6.0" [libraries] # CORE androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" } +androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" } androidx-ui = { group = "androidx.compose.ui", name = "ui" } androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" } androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview"} @@ -63,6 +65,9 @@ hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.r # SPLASH splash = { group = "androidx.core", name = "core-splashscreen", version.ref = "splashVersion" } +# COIL +coil = { group = "io.coil-kt", name = "coil-compose", version.ref = "coilVersion" } + # UNIT TEST junit = { group = "junit", name = "junit", version.ref = "junit4" } coroutine-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutineVersion" } From 84360dddc2cbfeff8959039eb823e94fa634326a Mon Sep 17 00:00:00 2001 From: LucasPrioste Date: Thu, 18 Apr 2024 15:39:10 +0100 Subject: [PATCH 16/24] - main carousel news add anim - empty screen when news are empty - loading shimmer effect while loading main news --- .../common/ConnectivityObserver.kt | 12 ++ .../pinkroom/marketsight/common/Constants.kt | 5 + .../common/NetworkConnectivityObserver.kt | 49 ++++++++ .../pinkroom/marketsight/common/Resource.kt | 1 - .../data/repository/NewsRepositoryImp.kt | 1 - .../dev/pinkroom/marketsight/di/AppModule.kt | 9 +- .../marketsight/domain/model/news/NewsInfo.kt | 6 +- .../domain/use_case/news/ChangeFilterNews.kt | 1 - .../ui/core/navigation/NavigationAppHost.kt | 4 + .../marketsight/ui/core/theme/Dimensions.kt | 2 +- .../marketsight/ui/core/theme/Theme.kt | 10 +- .../marketsight/ui/news_screen/NewsEvent.kt | 5 + .../marketsight/ui/news_screen/NewsScreen.kt | 52 +++++--- .../marketsight/ui/news_screen/NewsUiState.kt | 45 +------ .../ui/news_screen/NewsViewModel.kt | 59 +++++++++ .../news_screen/components/EmptyNewsList.kt | 53 ++++++++ .../components/IndicatorPageCarousel.kt | 14 ++- .../ui/news_screen/components/MainNews.kt | 113 +++++++++++++++--- .../ui/news_screen/components/MainNewsCard.kt | 28 +++-- app/src/main/res/drawable/icon_error.xml | 5 + app/src/main/res/values/strings.xml | 2 + 21 files changed, 383 insertions(+), 93 deletions(-) create mode 100644 app/src/main/java/dev/pinkroom/marketsight/common/ConnectivityObserver.kt create mode 100644 app/src/main/java/dev/pinkroom/marketsight/common/Constants.kt create mode 100644 app/src/main/java/dev/pinkroom/marketsight/common/NetworkConnectivityObserver.kt create mode 100644 app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsEvent.kt create mode 100644 app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/EmptyNewsList.kt create mode 100644 app/src/main/res/drawable/icon_error.xml diff --git a/app/src/main/java/dev/pinkroom/marketsight/common/ConnectivityObserver.kt b/app/src/main/java/dev/pinkroom/marketsight/common/ConnectivityObserver.kt new file mode 100644 index 0000000..e0be426 --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/common/ConnectivityObserver.kt @@ -0,0 +1,12 @@ +package dev.pinkroom.marketsight.common + +import kotlinx.coroutines.flow.Flow + +interface ConnectivityObserver { + + fun observe(): Flow + + enum class Status { + Available, Unavailable, Losing, Lost + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/common/Constants.kt b/app/src/main/java/dev/pinkroom/marketsight/common/Constants.kt new file mode 100644 index 0000000..67d61ca --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/common/Constants.kt @@ -0,0 +1,5 @@ +package dev.pinkroom.marketsight.common + +object Constants { + val ANIM_TIME_CAROUSEL = 700 +} \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/common/NetworkConnectivityObserver.kt b/app/src/main/java/dev/pinkroom/marketsight/common/NetworkConnectivityObserver.kt new file mode 100644 index 0000000..a1b9a35 --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/common/NetworkConnectivityObserver.kt @@ -0,0 +1,49 @@ +package dev.pinkroom.marketsight.common + +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch + +class NetworkConnectivityObserver( + context: Context +): ConnectivityObserver { + + private val connectivityManager = + context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + + override fun observe(): Flow { + return callbackFlow { + val callback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + super.onAvailable(network) + launch { send(ConnectivityObserver.Status.Available) } + } + + override fun onLosing(network: Network, maxMsToLive: Int) { + super.onLosing(network, maxMsToLive) + launch { send(ConnectivityObserver.Status.Losing) } + } + + override fun onLost(network: Network) { + super.onLost(network) + launch { send(ConnectivityObserver.Status.Lost) } + } + + override fun onUnavailable() { + super.onUnavailable() + launch { send(ConnectivityObserver.Status.Unavailable) } + } + } + + connectivityManager.registerDefaultNetworkCallback(callback) + awaitClose { + connectivityManager.unregisterNetworkCallback(callback) + } + }.distinctUntilChanged() + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/common/Resource.kt b/app/src/main/java/dev/pinkroom/marketsight/common/Resource.kt index 610ce0c..ce46c8c 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/common/Resource.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/common/Resource.kt @@ -9,5 +9,4 @@ sealed class Resource{ val errorInfo: ErrorMessage? = null, val data: T? = null ): Resource() - data class Loading(val isLoading: Boolean=true): Resource() } \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/data/repository/NewsRepositoryImp.kt b/app/src/main/java/dev/pinkroom/marketsight/data/repository/NewsRepositoryImp.kt index db23693..176ed11 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/data/repository/NewsRepositoryImp.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/data/repository/NewsRepositoryImp.kt @@ -40,7 +40,6 @@ class NewsRepositoryImp @Inject constructor( when(response){ is Resource.Error -> emit(Resource.Error(data = symbols)) is Resource.Success -> emit(Resource.Success(data = response.data.news ?: symbols)) - else -> Unit } } }.flowOn(dispatchers.IO).single() diff --git a/app/src/main/java/dev/pinkroom/marketsight/di/AppModule.kt b/app/src/main/java/dev/pinkroom/marketsight/di/AppModule.kt index 2896c7d..e5f2399 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/di/AppModule.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/di/AppModule.kt @@ -14,9 +14,11 @@ import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import dev.pinkroom.marketsight.BuildConfig import dev.pinkroom.marketsight.MarketSightApp +import dev.pinkroom.marketsight.common.ConnectivityObserver import dev.pinkroom.marketsight.common.DefaultDispatchers import dev.pinkroom.marketsight.common.DispatcherProvider import dev.pinkroom.marketsight.common.FlowStreamAdapterFactory +import dev.pinkroom.marketsight.common.NetworkConnectivityObserver import dev.pinkroom.marketsight.common.addAuthenticationInterceptor import dev.pinkroom.marketsight.common.addLoggingInterceptor import dev.pinkroom.marketsight.data.data_source.NewsRemoteDataSource @@ -83,7 +85,7 @@ object AppModule { @Provides @Singleton - fun provideDispatchers(): DispatcherProvider{ + fun provideDispatchers(): DispatcherProvider { return DefaultDispatchers() } @@ -92,4 +94,9 @@ object AppModule { fun provideNewsRepository(newsRemoteDataSource: NewsRemoteDataSource, dispatcherProvider: DispatcherProvider): NewsRepository { return NewsRepositoryImp(newsRemoteDataSource = newsRemoteDataSource, dispatchers = dispatcherProvider) } + + @Provides + @Singleton + fun provideConnectivityObserver(@ApplicationContext context: Context): ConnectivityObserver = + NetworkConnectivityObserver(context = context) } \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/domain/model/news/NewsInfo.kt b/app/src/main/java/dev/pinkroom/marketsight/domain/model/news/NewsInfo.kt index cd40d36..b0192b5 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/domain/model/news/NewsInfo.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/domain/model/news/NewsInfo.kt @@ -21,7 +21,7 @@ data class NewsInfo( ){ fun getImageUrl(imageSize: ImageSize) = images?.find { it.size == imageSize }?.url - fun getUpdatedDateFormatted(): String{ + fun getUpdatedDateFormatted(): String { val formatter = DateTimeFormatter .ofLocalizedDateTime(FormatStyle.LONG, FormatStyle.SHORT) .withLocale(Locale.getDefault()) @@ -29,4 +29,8 @@ data class NewsInfo( return updatedAt.format(formatter) } + + fun getAllSymbols(): String { + return symbols.joinToString(separator = ", ") + } } diff --git a/app/src/main/java/dev/pinkroom/marketsight/domain/use_case/news/ChangeFilterNews.kt b/app/src/main/java/dev/pinkroom/marketsight/domain/use_case/news/ChangeFilterNews.kt index d34b755..cc0ec2e 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/domain/use_case/news/ChangeFilterNews.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/domain/use_case/news/ChangeFilterNews.kt @@ -34,7 +34,6 @@ class ChangeFilterNews @Inject constructor( is Resource.Error -> { symbolsToRevert.addAll(subscribeSymbols) } - else -> Unit } } diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/NavigationAppHost.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/NavigationAppHost.kt index 68a7d28..5b832b7 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/NavigationAppHost.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/NavigationAppHost.kt @@ -41,8 +41,12 @@ fun NavigationAppHost( NewsScreen( news = uiState.news, + mainNews = uiState.mainNews, realTimeNews = uiState.realTimeNews, symbols = uiState.symbols, + isLoading = uiState.isLoading, + errorMessage = uiState.errorMessage, + onEvent = viewModel::onEvent ) } composable( diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Dimensions.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Dimensions.kt index 3b44de8..dcaa520 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Dimensions.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Dimensions.kt @@ -17,7 +17,7 @@ class Dimensions( val headlineMedium: TextUnit = 28.sp, val headlineSmall: TextUnit = 24.sp, val titleLarge: TextUnit = 22.sp, - val titleMedium: TextUnit = 16.sp, + val titleMedium: TextUnit = 19.sp, val titleSmall: TextUnit = 14.sp, val bodyLarge: TextUnit = 16.sp, val bodyMedium: TextUnit = 14.sp, diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Theme.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Theme.kt index eb8cf37..f4c80b1 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Theme.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Theme.kt @@ -16,23 +16,25 @@ import androidx.compose.ui.platform.LocalView import androidx.core.view.WindowCompat private val DarkColorScheme = darkColorScheme( - primary = WoodSmoke, + primary = Gray, onPrimary = White, surfaceVariant = Gray, surface = WoodSmoke, onSurfaceVariant = White, background = WoodSmoke, onBackground = White, + tertiaryContainer = Gray, ) private val LightColorScheme = lightColorScheme( - primary = GrayAthens, + primary = White, onPrimary = Black, surfaceVariant = White, surface = GrayAthens, onSurfaceVariant = Black, background = GrayAthens, onBackground = Black, + tertiaryContainer = Black, ) @Composable @@ -53,8 +55,8 @@ fun MarketSightTheme( if (!view.isInEditMode) { SideEffect { val window = (view.context as Activity).window - window.statusBarColor = colorScheme.primary.toArgb() - window.navigationBarColor = colorScheme.primary.toArgb() + window.statusBarColor = colorScheme.background.toArgb() + window.navigationBarColor = colorScheme.background.toArgb() WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme } } diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsEvent.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsEvent.kt new file mode 100644 index 0000000..d407f0b --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsEvent.kt @@ -0,0 +1,5 @@ +package dev.pinkroom.marketsight.ui.news_screen + +sealed class NewsEvent { + data object RetryNews: NewsEvent() +} diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsScreen.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsScreen.kt index e326004..2e45b1f 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsScreen.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsScreen.kt @@ -5,29 +5,34 @@ import android.net.Uri import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview +import dev.pinkroom.marketsight.R import dev.pinkroom.marketsight.domain.model.common.SubInfoSymbols import dev.pinkroom.marketsight.domain.model.news.ImageSize import dev.pinkroom.marketsight.domain.model.news.ImagesNews import dev.pinkroom.marketsight.domain.model.news.NewsInfo import dev.pinkroom.marketsight.ui.core.theme.dimens +import dev.pinkroom.marketsight.ui.news_screen.components.EmptyNewsList import dev.pinkroom.marketsight.ui.news_screen.components.MainNews import java.time.LocalDateTime @Composable fun NewsScreen( modifier: Modifier = Modifier, + mainNews: List, news: List, realTimeNews: List, symbols: List, + isLoading: Boolean, + errorMessage: Int? = null, + onEvent: (event: NewsEvent) -> Unit, ){ val context = LocalContext.current - + LazyColumn( modifier = modifier .fillMaxSize(), @@ -36,19 +41,28 @@ fun NewsScreen( bottom = dimens.contentBottomPadding, ) ) { - item { - MainNews( - modifier = Modifier - .fillMaxWidth() - .padding( - horizontal = dimens.horizontalPadding, - ), - newsList = news, - onNewsClick = { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(it.url)) - context.startActivity(intent) - } - ) + if (!isLoading && news.isEmpty()){ + item { + EmptyNewsList( + modifier = Modifier + .fillParentMaxSize(), + errorMessage = errorMessage ?: R.string.get_news_error_message, + onEvent = onEvent, + ) + } + } else { + item { + MainNews( + modifier = Modifier + .fillMaxWidth(), + newsList = mainNews, + isLoading = isLoading, + onNewsClick = { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(it.url)) + context.startActivity(intent) + } + ) + } } } } @@ -60,7 +74,8 @@ fun NewsScreen( @Composable fun NewsScreenPreview(){ NewsScreen( - news = listOf( + news = listOf(), + mainNews = listOf( NewsInfo( id = 1L, symbols = listOf("TSLA","AAPL","BTC"), @@ -79,7 +94,7 @@ fun NewsScreenPreview(){ headline = "Market Clubhouse Morning Memo - April 11th, 2024 (Trade Strategy For SPY, QQQ, AAPL, MSFT, NVDA, GOOGL, META, And TSLA)", ), NewsInfo( - id = 1L, + id = 2L, symbols = listOf("TSLA","AAPL","BTC"), images = listOf( ImagesNews( @@ -118,5 +133,8 @@ fun NewsScreenPreview(){ name = "APPLE", symbol = "AAPL", ), ), + isLoading = true, + errorMessage = R.string.get_news_error_message, + onEvent = {}, ) } \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsUiState.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsUiState.kt index 4ecb74c..43348b4 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsUiState.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsUiState.kt @@ -1,49 +1,14 @@ package dev.pinkroom.marketsight.ui.news_screen +import androidx.annotation.DrawableRes import dev.pinkroom.marketsight.domain.model.common.SubInfoSymbols -import dev.pinkroom.marketsight.domain.model.news.ImageSize -import dev.pinkroom.marketsight.domain.model.news.ImagesNews import dev.pinkroom.marketsight.domain.model.news.NewsInfo -import java.time.LocalDateTime data class NewsUiState( - val isLoading: Boolean = false, - val news: List = listOf( - NewsInfo( - id = 1L, - symbols = listOf("TSLA","AAPL","BTC"), - images = listOf( - ImagesNews( - size = ImageSize.Small, - url = "https://cdn.benzinga.com/files/imagecache/1024x768xUP/market-clubhouse-morning-memo_201.png", - ) - ), - source = "Bezinga", - url = "https://www.benzinga.com/", - updatedAt = LocalDateTime.now(), - createdAt = LocalDateTime.now(), - author = "RIPS", - summary = "Good Morning Traders! In today's Market Clubhouse Morning Memo, we will discuss SPY, QQQ, AAPL, MSFT, NVDA, GOOGL, META, and TSLA.", - headline = "Market Clubhouse Morning Memo - April 11th, 2024 (Trade Strategy For SPY, QQQ, AAPL, MSFT, NVDA, GOOGL, META, And TSLA)", - ), - NewsInfo( - id = 2L, - symbols = listOf("TSLA","AAPL","BTC"), - images = listOf( - ImagesNews( - size = ImageSize.Small, - url = "https://cdn.benzinga.com/files/imagecache/1024x768xUP/market-clubhouse-morning-memo_201.p", - ) - ), - source = "Bezinga", - url = "https://www.benzinga.com/", - updatedAt = LocalDateTime.now(), - createdAt = LocalDateTime.now(), - author = "RIPS", - summary = "Good Morning Traders! In today's Market Clubhouse Morning Memo, we will discuss SPY, QQQ, AAPL, MSFT, NVDA, GOOGL, META, and TSLA.", - headline = "Market Clubhouse Morning Memo - April 11th, 2024 (Trade Strategy For SPY, QQQ, AAPL, MSFT, NVDA, GOOGL, META, And TSLA)", - ), - ), + val isLoading: Boolean = true, + @DrawableRes val errorMessage: Int? = null, + val mainNews: List = listOf(), + val news: List = listOf(), val realTimeNews: List = listOf(), val symbols: List = listOf(), ) diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsViewModel.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsViewModel.kt index 4cec0a4..bef78e1 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsViewModel.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsViewModel.kt @@ -1,13 +1,23 @@ package dev.pinkroom.marketsight.ui.news_screen import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import dev.pinkroom.marketsight.R +import dev.pinkroom.marketsight.common.ConnectivityObserver +import dev.pinkroom.marketsight.common.ConnectivityObserver.Status.Available +import dev.pinkroom.marketsight.common.DispatcherProvider +import dev.pinkroom.marketsight.common.Resource import dev.pinkroom.marketsight.domain.use_case.news.ChangeFilterNews import dev.pinkroom.marketsight.domain.use_case.news.GetNews import dev.pinkroom.marketsight.domain.use_case.news.GetRealTimeNews import dev.pinkroom.marketsight.domain.use_case.news.SubscribeNews +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel @@ -16,11 +26,60 @@ class NewsViewModel @Inject constructor( private val getRealTimeNews: GetRealTimeNews, private val getNews: GetNews, private val changeFilterNews: ChangeFilterNews, + private val connectivityObserver: ConnectivityObserver, + private val dispatchers: DispatcherProvider, ): ViewModel() { private val _uiState = MutableStateFlow(NewsUiState()) val uiState = _uiState.asStateFlow() + private var initNewsJob: Job? = null + init { + observeNetworkStatus() + initNews() + } + + fun onEvent(event: NewsEvent){ + when(event){ + NewsEvent.RetryNews -> retryToGetNews() + } + } + + private fun observeNetworkStatus(){ + viewModelScope.launch(dispatchers.IO) { + connectivityObserver.observe().distinctUntilChanged().collect{ statusNet -> + if (statusNet == Available && uiState.value.news.isEmpty() && initNewsJob == null) + initNews() + } + } + } + + private fun initNews(){ + initNewsJob = viewModelScope.launch(dispatchers.IO) { + _uiState.update { it.copy(isLoading = true) } + when(val response = getNews()){ + is Resource.Success -> { + val allNews = response.data.news + val maxNumberNews = if (allNews.size >= 5) 5 else allNews.size + val mainNews = allNews.subList(fromIndex = 0, toIndex = maxNumberNews) + _uiState.update { + it.copy( + news = response.data.news, mainNews = mainNews, + isLoading = false, errorMessage = null, + ) + } + } + is Resource.Error -> { + _uiState.update { + it.copy(isLoading = false, errorMessage = R.string.get_news_error_message) + } + } + } + initNewsJob = null + } + } + private fun retryToGetNews() { + if (initNewsJob == null) initNews() } } \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/EmptyNewsList.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/EmptyNewsList.kt new file mode 100644 index 0000000..b455a58 --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/EmptyNewsList.kt @@ -0,0 +1,53 @@ +package dev.pinkroom.marketsight.ui.news_screen.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import dev.pinkroom.marketsight.R +import dev.pinkroom.marketsight.ui.core.theme.dimens +import dev.pinkroom.marketsight.ui.news_screen.NewsEvent + +@Composable +fun EmptyNewsList( + modifier: Modifier = Modifier, + errorMessage: Int, + onEvent: (event: NewsEvent) -> Unit, +){ + Column( + modifier = modifier + .padding(horizontal = dimens.horizontalPadding), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + modifier = Modifier.size(dimens.largeIconSize), + painter = painterResource(id = R.drawable.icon_error), + contentDescription = null, + ) + Spacer(modifier = Modifier.height(dimens.smallPadding)) + Text( + text = stringResource(id = errorMessage), + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(dimens.normalPadding)) + Button( + onClick = { onEvent(NewsEvent.RetryNews) } + ) { + Text( + text = stringResource(id = R.string.retry) + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/IndicatorPageCarousel.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/IndicatorPageCarousel.kt index 85e96cb..25c9640 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/IndicatorPageCarousel.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/IndicatorPageCarousel.kt @@ -1,5 +1,6 @@ package dev.pinkroom.marketsight.ui.news_screen.components +import androidx.compose.animation.core.tween import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -18,6 +19,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import dev.pinkroom.marketsight.common.Constants import dev.pinkroom.marketsight.ui.core.theme.PhilippineGray import dev.pinkroom.marketsight.ui.core.theme.PhilippineSilver import dev.pinkroom.marketsight.ui.core.theme.dimens @@ -27,6 +29,7 @@ import kotlinx.coroutines.launch @Composable fun IndicatorPageCarousel( pagerState: PagerState, + changePage: (currentPage: Int) -> Unit, ){ val scope = rememberCoroutineScope() val interactionSource = remember { MutableInteractionSource() } @@ -50,10 +53,17 @@ fun IndicatorPageCarousel( indication = null, interactionSource = interactionSource, onClick = { - if (pagerState.currentPage != iteration) + if (pagerState.currentPage != iteration) { scope.launch { - pagerState.animateScrollToPage(page = iteration) + pagerState.animateScrollToPage( + page = iteration, + animationSpec = tween( + durationMillis = Constants.ANIM_TIME_CAROUSEL + ) + ) } + changePage(iteration) + } } ), ) diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/MainNews.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/MainNews.kt index cdbff8a..da9e171 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/MainNews.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/MainNews.kt @@ -1,44 +1,125 @@ package dev.pinkroom.marketsight.ui.news_screen.components +import androidx.compose.animation.core.tween import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.interaction.collectIsDraggedAsState +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerState import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.util.lerp +import dev.pinkroom.marketsight.common.Constants import dev.pinkroom.marketsight.domain.model.news.NewsInfo +import dev.pinkroom.marketsight.domain.model.news.getAspectRatio import dev.pinkroom.marketsight.ui.core.theme.dimens +import dev.pinkroom.marketsight.ui.core.theme.shimmerEffect +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.math.absoluteValue @OptIn(ExperimentalFoundationApi::class) @Composable fun MainNews( modifier: Modifier = Modifier, newsList: List, + isLoading: Boolean, onNewsClick: (news: NewsInfo) -> Unit, + autoScrollDuration: Long = 5000L, ){ val pagerState = rememberPagerState( pageCount = { newsList.size } ) - Column( - modifier = Modifier - .fillMaxWidth(), - ) { - HorizontalPager( - state = pagerState, - key = { newsList[it].id } - ) { index -> - val news = newsList[index] - MainNewsCard( - modifier = modifier, - news = news, - onClick = onNewsClick + val isDragged by pagerState.interactionSource.collectIsDraggedAsState() + var currentPageKey by remember { mutableIntStateOf(pagerState.currentPage) } + if (isDragged.not() && newsList.isNotEmpty()) { + with(pagerState) { + LaunchedEffect(key1 = currentPageKey) { + launch { + delay(timeMillis = autoScrollDuration) + val nextPage = (currentPage + 1).mod(pageCount) + animateScrollToPage( + page = nextPage, + animationSpec = tween( + durationMillis = Constants.ANIM_TIME_CAROUSEL + ) + ) + currentPageKey = nextPage + } + } + } + } + + if (isLoading) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = dimens.horizontalPadding) + .shadow( + elevation = dimens.normalElevation, + shape = RoundedCornerShape(size = dimens.normalShape) + ) + .aspectRatio(dimens.imageSizeMainNews.getAspectRatio()) + .shimmerEffect() + ) + } else { + Column( + modifier = Modifier + .fillMaxWidth(), + ) { + HorizontalPager( + state = pagerState, + key = { newsList[it].id }, + contentPadding = PaddingValues(horizontal = dimens.horizontalPadding), + pageSpacing = dimens.normalPadding + ) { index -> + val news = newsList[index] + MainNewsCard( + modifier = modifier + .carouselTransition(index, pagerState), + news = news, + onClick = onNewsClick + ) + } + Spacer(modifier = Modifier.height(dimens.smallPadding)) + IndicatorPageCarousel( + pagerState = pagerState, + changePage = { + currentPageKey = it + } ) } - Spacer(modifier = Modifier.height(dimens.smallPadding)) - IndicatorPageCarousel(pagerState = pagerState) } -} \ No newline at end of file +} + +@OptIn(ExperimentalFoundationApi::class) +fun Modifier.carouselTransition(page: Int, pagerState: PagerState) = + graphicsLayer { + val pageOffset = + ((pagerState.currentPage - page) + pagerState.currentPageOffsetFraction).absoluteValue + + val transformation = lerp( + start = 0.7f, + stop = 1f, + fraction = 1f - pageOffset.coerceIn(0f, 1f) + ) + alpha = transformation + scaleY = transformation + } \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/MainNewsCard.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/MainNewsCard.kt index b4b438b..e7ca306 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/MainNewsCard.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/MainNewsCard.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -115,19 +116,30 @@ fun MainNewsCard( ) { Text( text = news.headline, - style = MaterialTheme.typography.headlineSmall, + style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Medium, color = White, maxLines = 2, overflow = TextOverflow.Ellipsis, ) - Text( - modifier = Modifier.fillMaxWidth(), - text = news.getUpdatedDateFormatted(), - fontWeight = FontWeight.Bold, - color = White, - textAlign = TextAlign.Start, - ) + Row { + Text( + modifier = Modifier.weight(1f), + text = news.getUpdatedDateFormatted(), + color = White, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Start, + ) + Text( + modifier = Modifier.weight(0.8f), + textAlign = TextAlign.End, + text = news.getAllSymbols(), + fontWeight = FontWeight.Bold, + color = White, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } } } diff --git a/app/src/main/res/drawable/icon_error.xml b/app/src/main/res/drawable/icon_error.xml new file mode 100644 index 0000000..ee34fd7 --- /dev/null +++ b/app/src/main/res/drawable/icon_error.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 32fce48..7ce213a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3,4 +3,6 @@ News Home icon bottom bar + Something went wrong, check your internet connection. + Retry \ No newline at end of file From aac9174b335ed594d191ace4ff0f7563e1c2f1b4 Mon Sep 17 00:00:00 2001 From: LucasPrioste Date: Fri, 19 Apr 2024 11:56:55 +0100 Subject: [PATCH 17/24] - show all news - refresh news - show snackbar error - refactor code --- .../pinkroom/marketsight/common/Constants.kt | 3 +- .../dev/pinkroom/marketsight/di/AppModule.kt | 33 ++++- .../pinkroom/marketsight/ui/MainActivity.kt | 21 ++- .../ui/core/components/ObserveAsEvents.kt | 18 +++ .../components/PullToRefreshLazyColumn.kt | 69 ++++++++++ .../ui/core/navigation/NavigationAppHost.kt | 15 +++ .../ui/core/navigation/NavigationBottomBar.kt | 16 ++- .../marketsight/ui/core/theme/Dimensions.kt | 5 + .../marketsight/ui/news_screen/NewsAction.kt | 11 ++ .../marketsight/ui/news_screen/NewsEvent.kt | 1 + .../marketsight/ui/news_screen/NewsScreen.kt | 79 ++++++++++-- .../marketsight/ui/news_screen/NewsUiState.kt | 1 + .../ui/news_screen/NewsViewModel.kt | 38 +++++- .../ui/news_screen/components/AllNews.kt | 67 ++++++++++ .../ui/news_screen/components/AllNewsCard.kt | 121 ++++++++++++++++++ .../news_screen/components/EmptyNewsList.kt | 5 +- .../ui/news_screen/components/ImageNews.kt | 41 ++++++ .../ui/news_screen/components/MainNews.kt | 2 +- .../ui/news_screen/components/MainNewsCard.kt | 34 +---- app/src/main/res/values/strings.xml | 2 +- 20 files changed, 518 insertions(+), 64 deletions(-) create mode 100644 app/src/main/java/dev/pinkroom/marketsight/ui/core/components/ObserveAsEvents.kt create mode 100644 app/src/main/java/dev/pinkroom/marketsight/ui/core/components/PullToRefreshLazyColumn.kt create mode 100644 app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsAction.kt create mode 100644 app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/AllNews.kt create mode 100644 app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/AllNewsCard.kt create mode 100644 app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/ImageNews.kt diff --git a/app/src/main/java/dev/pinkroom/marketsight/common/Constants.kt b/app/src/main/java/dev/pinkroom/marketsight/common/Constants.kt index 67d61ca..529f4d0 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/common/Constants.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/common/Constants.kt @@ -1,5 +1,6 @@ package dev.pinkroom.marketsight.common object Constants { - val ANIM_TIME_CAROUSEL = 700 + const val ANIM_TIME_CAROUSEL = 700 + const val MAX_ITEMS_CAROUSEL = 5 } \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/di/AppModule.kt b/app/src/main/java/dev/pinkroom/marketsight/di/AppModule.kt index e5f2399..8f6881b 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/di/AppModule.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/di/AppModule.kt @@ -31,6 +31,7 @@ import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory import retrofit2.create import java.util.concurrent.TimeUnit +import javax.inject.Named import javax.inject.Singleton @Module @@ -38,13 +39,29 @@ import javax.inject.Singleton object AppModule { private const val ALPACA_STREAM_URL_NEWS = BuildConfig.ALPACA_STREAM_URL + "v1beta1/news" - private const val API_TIMEOUT = 60L + private const val API_TIMEOUT_WS = 30L + private const val API_TIMEOUT_API = 10L + private const val OK_HTTP_WS = "okHttpWS" + private const val OK_HTTP_API = "okHttpAPI" + + @Provides + @Singleton + @Named(OK_HTTP_WS) + fun provideOkHttpClientWS() = OkHttpClient.Builder() + .connectTimeout(API_TIMEOUT_WS, TimeUnit.SECONDS) + .readTimeout(API_TIMEOUT_WS, TimeUnit.SECONDS) + .writeTimeout(API_TIMEOUT_WS, TimeUnit.SECONDS) + .addAuthenticationInterceptor() + .addLoggingInterceptor() + .build() + @Provides @Singleton - fun provideOkHttpClient() = OkHttpClient.Builder() - .connectTimeout(API_TIMEOUT, TimeUnit.SECONDS) - .readTimeout(API_TIMEOUT, TimeUnit.SECONDS) - .writeTimeout(API_TIMEOUT, TimeUnit.SECONDS) + @Named(OK_HTTP_API) + fun provideOkHttpClientAPI() = OkHttpClient.Builder() + .connectTimeout(API_TIMEOUT_API, TimeUnit.SECONDS) + .readTimeout(API_TIMEOUT_API, TimeUnit.SECONDS) + .writeTimeout(API_TIMEOUT_API, TimeUnit.SECONDS) .addAuthenticationInterceptor() .addLoggingInterceptor() .build() @@ -57,7 +74,9 @@ object AppModule { @Provides @Singleton - fun provideAlpacaNewsService(okHttpClient: OkHttpClient, lifecycle: Lifecycle): AlpacaNewsService { + fun provideAlpacaNewsService( + @Named(OK_HTTP_WS) okHttpClient: OkHttpClient, lifecycle: Lifecycle + ): AlpacaNewsService { val scarlet = Scarlet.Builder() .webSocketFactory(okHttpClient.newWebSocketFactory(ALPACA_STREAM_URL_NEWS)) .addMessageAdapterFactory(GsonMessageAdapter.Factory()) @@ -70,7 +89,7 @@ object AppModule { @Provides @Singleton - fun provideAlpacaNewsApi(okHttpClient: OkHttpClient): AlpacaNewsApi { + fun provideAlpacaNewsApi(@Named(OK_HTTP_API) okHttpClient: OkHttpClient): AlpacaNewsApi { return Retrofit.Builder() .baseUrl(BuildConfig.ALPACA_DATA_URL) .addConverterFactory(GsonConverterFactory.create()) diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/MainActivity.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/MainActivity.kt index 6bd6322..61c58e5 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/MainActivity.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/MainActivity.kt @@ -6,7 +6,11 @@ import androidx.activity.compose.setContent import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Surface +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.navigation.compose.rememberNavController @@ -15,6 +19,7 @@ import dev.pinkroom.marketsight.ui.core.navigation.NavigationAppHost import dev.pinkroom.marketsight.ui.core.navigation.NavigationBottomBar import dev.pinkroom.marketsight.ui.core.navigation.Route import dev.pinkroom.marketsight.ui.core.theme.MarketSightTheme +import kotlinx.coroutines.launch @AndroidEntryPoint class MainActivity : ComponentActivity() { @@ -25,13 +30,17 @@ class MainActivity : ComponentActivity() { setContent { val navController = rememberNavController() val startDestination = Route.NewsScreen - + val snackBarHostState = remember { SnackbarHostState() } + val scope = rememberCoroutineScope() MarketSightTheme { Surface( modifier = Modifier.fillMaxSize(), ) { Scaffold( modifier = Modifier.fillMaxSize(), + snackbarHost = { + SnackbarHost(hostState = snackBarHostState) + }, bottomBar = { NavigationBottomBar( navController = navController, @@ -43,6 +52,16 @@ class MainActivity : ComponentActivity() { modifier = Modifier.padding(padding), navController = navController, startDestination = startDestination, + onShowSnackBar = { message, duration -> + scope.launch { + snackBarHostState.currentSnackbarData?.dismiss() + snackBarHostState.showSnackbar( + message = message, + duration = duration, + withDismissAction = true, + ) + } + } ) } } diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/core/components/ObserveAsEvents.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/core/components/ObserveAsEvents.kt new file mode 100644 index 0000000..a8e9d5e --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/core/components/ObserveAsEvents.kt @@ -0,0 +1,18 @@ +package dev.pinkroom.marketsight.ui.core.components + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.repeatOnLifecycle +import kotlinx.coroutines.flow.Flow + +@Composable +fun ObserveAsEvents(flow: Flow, onEvent: (T) -> Unit) { + val lifecycleOwner = LocalLifecycleOwner.current + LaunchedEffect(flow, lifecycleOwner.lifecycle) { + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + flow.collect(onEvent) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/core/components/PullToRefreshLazyColumn.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/core/components/PullToRefreshLazyColumn.kt new file mode 100644 index 0000000..9f36a0e --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/core/components/PullToRefreshLazyColumn.kt @@ -0,0 +1,69 @@ +package dev.pinkroom.marketsight.ui.core.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.pulltorefresh.PullToRefreshContainer +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.unit.dp + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PullToRefreshLazyColumn( + modifier: Modifier = Modifier, + isRefreshing: Boolean, + enabledPullToRefresh: (() -> Boolean)? = null, + onRefresh: () -> Unit, + contentPadding: PaddingValues = PaddingValues(), + lazyListState: LazyListState = rememberLazyListState(), + content: (LazyListScope.() -> Unit), +) { + val pullToRefreshState = rememberPullToRefreshState( + enabled = enabledPullToRefresh ?: { true } + ) + Box( + modifier = modifier + .nestedScroll(pullToRefreshState.nestedScrollConnection), + ) { + LazyColumn( + state = lazyListState, + contentPadding = contentPadding, + modifier = Modifier + .fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(8.dp) + ){ + content() + } + + if(pullToRefreshState.isRefreshing) { + LaunchedEffect(true) { + onRefresh() + } + } + + LaunchedEffect(isRefreshing) { + if(isRefreshing) { + pullToRefreshState.startRefresh() + } else { + pullToRefreshState.endRefresh() + } + } + + PullToRefreshContainer( + state = pullToRefreshState, + modifier = Modifier + .align(Alignment.TopCenter), + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/NavigationAppHost.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/NavigationAppHost.kt index 5b832b7..d19468e 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/NavigationAppHost.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/NavigationAppHost.kt @@ -1,7 +1,9 @@ package dev.pinkroom.marketsight.ui.core.navigation +import androidx.compose.material3.SnackbarDuration import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController @@ -9,10 +11,12 @@ import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.navArgument +import dev.pinkroom.marketsight.ui.core.components.ObserveAsEvents import dev.pinkroom.marketsight.ui.core.navigation.Args.SYMBOL_ID import dev.pinkroom.marketsight.ui.detail_screen.DetailScreen import dev.pinkroom.marketsight.ui.home_screen.HomeScreen import dev.pinkroom.marketsight.ui.home_screen.HomeViewModel +import dev.pinkroom.marketsight.ui.news_screen.NewsAction import dev.pinkroom.marketsight.ui.news_screen.NewsScreen import dev.pinkroom.marketsight.ui.news_screen.NewsViewModel @@ -21,7 +25,9 @@ fun NavigationAppHost( modifier: Modifier = Modifier, navController: NavHostController, startDestination: Route, + onShowSnackBar: (message: String, duration: SnackbarDuration) -> Unit, ){ + val context = LocalContext.current NavHost( navController = navController, startDestination = startDestination.route, @@ -39,12 +45,21 @@ fun NavigationAppHost( val viewModel = hiltViewModel() val uiState = viewModel.uiState.collectAsStateWithLifecycle().value + ObserveAsEvents(viewModel.action){ action -> + when(action){ + is NewsAction.ShowSnackBar -> { + onShowSnackBar(context.getString(action.message), action.duration) + } + } + } + NewsScreen( news = uiState.news, mainNews = uiState.mainNews, realTimeNews = uiState.realTimeNews, symbols = uiState.symbols, isLoading = uiState.isLoading, + isRefreshing = uiState.isRefreshing, errorMessage = uiState.errorMessage, onEvent = viewModel::onEvent ) diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/NavigationBottomBar.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/NavigationBottomBar.kt index e73dc8d..1dedf4b 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/NavigationBottomBar.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/NavigationBottomBar.kt @@ -5,8 +5,12 @@ import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.tween import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape @@ -14,7 +18,6 @@ import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationBarItemDefaults import androidx.compose.runtime.Composable @@ -22,6 +25,7 @@ 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.painterResource @@ -59,7 +63,9 @@ fun NavigationBottomBar( content = { Card( modifier = Modifier + .background(color = Color.Transparent) .fillMaxWidth() + .height(dimens.menuHeight) .padding(horizontal = dimens.horizontalPadding) .padding(bottom = dimens.menuBottomPadding), shape = RoundedCornerShape(dimens.smallShape), @@ -67,8 +73,12 @@ fun NavigationBottomBar( defaultElevation = dimens.lowElevation, ), ) { - NavigationBar( - containerColor = Color.Transparent + Row( + modifier = Modifier + .background(Color.Transparent) + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically ) { items.forEach { bottomBarItem -> AddItem( diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Dimensions.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Dimensions.kt index dcaa520..8367c8b 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Dimensions.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Dimensions.kt @@ -57,6 +57,8 @@ class Dimensions( val imageSizeMainNews: ImageSize = ImageSize.Small, val circlePageIndicatorSize: Dp = 9.dp, val spaceBetweenPageIndicator: Dp = 2.dp, + val newsCard: Dp = 120.dp, + val menuHeight: Dp = 100.dp ) val dimens: Dimensions @@ -71,6 +73,9 @@ val dimens: Dimensions // Here you will override the dimensions needed depending on the screen size private val smallDimensions = Dimensions( + largeIconSize = 24.dp, + normalIconSize = 15.dp, + menuHeight = 70.dp, imageSizeMainNews = ImageSize.Small, ) private val normalDimensions = Dimensions( diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsAction.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsAction.kt new file mode 100644 index 0000000..46aae29 --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsAction.kt @@ -0,0 +1,11 @@ +package dev.pinkroom.marketsight.ui.news_screen + +import androidx.annotation.StringRes +import androidx.compose.material3.SnackbarDuration + +sealed class NewsAction{ + data class ShowSnackBar( + @StringRes val message: Int, + val duration: SnackbarDuration = SnackbarDuration.Short, + ): NewsAction() +} diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsEvent.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsEvent.kt index d407f0b..0f66a54 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsEvent.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsEvent.kt @@ -2,4 +2,5 @@ package dev.pinkroom.marketsight.ui.news_screen sealed class NewsEvent { data object RetryNews: NewsEvent() + data object RefreshNews: NewsEvent() } diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsScreen.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsScreen.kt index 2e45b1f..dbce9d0 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsScreen.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsScreen.kt @@ -1,11 +1,11 @@ package dev.pinkroom.marketsight.ui.news_screen +import android.content.Context import android.content.Intent import android.net.Uri import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -15,7 +15,9 @@ import dev.pinkroom.marketsight.domain.model.common.SubInfoSymbols import dev.pinkroom.marketsight.domain.model.news.ImageSize import dev.pinkroom.marketsight.domain.model.news.ImagesNews import dev.pinkroom.marketsight.domain.model.news.NewsInfo +import dev.pinkroom.marketsight.ui.core.components.PullToRefreshLazyColumn import dev.pinkroom.marketsight.ui.core.theme.dimens +import dev.pinkroom.marketsight.ui.news_screen.components.AllNews import dev.pinkroom.marketsight.ui.news_screen.components.EmptyNewsList import dev.pinkroom.marketsight.ui.news_screen.components.MainNews import java.time.LocalDateTime @@ -28,26 +30,36 @@ fun NewsScreen( realTimeNews: List, symbols: List, isLoading: Boolean, + isRefreshing: Boolean, errorMessage: Int? = null, onEvent: (event: NewsEvent) -> Unit, ){ val context = LocalContext.current - LazyColumn( + PullToRefreshLazyColumn( modifier = modifier .fillMaxSize(), contentPadding = PaddingValues( top = dimens.contentTopPadding, bottom = dimens.contentBottomPadding, - ) + ), + isRefreshing = isRefreshing, + enabledPullToRefresh = { + !((!isLoading && errorMessage != null && news.isEmpty()) || isLoading) + }, + onRefresh = { + onEvent(NewsEvent.RefreshNews) + }, ) { - if (!isLoading && news.isEmpty()){ + if (!isLoading && errorMessage != null && news.isEmpty()){ item { EmptyNewsList( modifier = Modifier .fillParentMaxSize(), - errorMessage = errorMessage ?: R.string.get_news_error_message, - onEvent = onEvent, + errorMessage = errorMessage, + onRetry = { + onEvent(NewsEvent.RetryNews) + }, ) } } else { @@ -58,15 +70,26 @@ fun NewsScreen( newsList = mainNews, isLoading = isLoading, onNewsClick = { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(it.url)) - context.startActivity(intent) + context.navigateToNews(newsInfo = it) } ) } + AllNews( + news = news, + isLoading = isLoading, + navigateToNews = { + context.navigateToNews(newsInfo = it) + } + ) } } } +fun Context.navigateToNews(newsInfo: NewsInfo){ + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(newsInfo.url)) + startActivity(intent) +} + @Preview( showBackground = true, showSystemUi = true, @@ -74,7 +97,42 @@ fun NewsScreen( @Composable fun NewsScreenPreview(){ NewsScreen( - news = listOf(), + news = listOf( + NewsInfo( + id = 1L, + symbols = listOf("TSLA","AAPL","BTC"), + images = listOf( + ImagesNews( + size = ImageSize.Small, + url = "https://cdn.benzinga.com/files/imagecache/1024x768xUP/market-clubhouse-morning-memo_201.png", + ) + ), + source = "Bezinga", + url = "https://www.benzinga.com/", + updatedAt = LocalDateTime.now(), + createdAt = LocalDateTime.now(), + author = "RIPS", + summary = "Good Morning Traders! In today's Market Clubhouse Morning Memo, we will discuss SPY, QQQ, AAPL, MSFT, NVDA, GOOGL, META, and TSLA.", + headline = "Market Clubhouse Morning Memo - April 11th, 2024 (Trade Strategy For SPY, QQQ, AAPL, MSFT, NVDA, GOOGL, META, And TSLA)", + ), + NewsInfo( + id = 2L, + symbols = listOf("TSLA","AAPL","BTC"), + images = listOf( + ImagesNews( + size = ImageSize.Small, + url = "https://cdn.benzinga.com/files/imagecache/1024x768xUP/market-clubhouse-morning-memo_201.png", + ) + ), + source = "Bezinga", + url = "https://www.benzinga.com/", + updatedAt = LocalDateTime.now(), + createdAt = LocalDateTime.now(), + author = "RIPS", + summary = "Good Morning Traders! In today's Market Clubhouse Morning Memo, we will discuss SPY, QQQ, AAPL, MSFT, NVDA, GOOGL, META, and TSLA.", + headline = "Market Clubhouse Morning Memo - April 11th, 2024 (Trade Strategy For SPY, QQQ, AAPL, MSFT, NVDA, GOOGL, META, And TSLA)", + ), + ), mainNews = listOf( NewsInfo( id = 1L, @@ -133,8 +191,9 @@ fun NewsScreenPreview(){ name = "APPLE", symbol = "AAPL", ), ), - isLoading = true, + isLoading = false, errorMessage = R.string.get_news_error_message, + isRefreshing = false, onEvent = {}, ) } \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsUiState.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsUiState.kt index 43348b4..638ceb3 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsUiState.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsUiState.kt @@ -6,6 +6,7 @@ import dev.pinkroom.marketsight.domain.model.news.NewsInfo data class NewsUiState( val isLoading: Boolean = true, + val isRefreshing: Boolean = false, @DrawableRes val errorMessage: Int? = null, val mainNews: List = listOf(), val news: List = listOf(), diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsViewModel.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsViewModel.kt index bef78e1..65b4fc8 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsViewModel.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsViewModel.kt @@ -6,6 +6,8 @@ import dagger.hilt.android.lifecycle.HiltViewModel import dev.pinkroom.marketsight.R import dev.pinkroom.marketsight.common.ConnectivityObserver import dev.pinkroom.marketsight.common.ConnectivityObserver.Status.Available +import dev.pinkroom.marketsight.common.ConnectivityObserver.Status.Unavailable +import dev.pinkroom.marketsight.common.Constants.MAX_ITEMS_CAROUSEL import dev.pinkroom.marketsight.common.DispatcherProvider import dev.pinkroom.marketsight.common.Resource import dev.pinkroom.marketsight.domain.use_case.news.ChangeFilterNews @@ -13,9 +15,11 @@ import dev.pinkroom.marketsight.domain.use_case.news.GetNews import dev.pinkroom.marketsight.domain.use_case.news.GetRealTimeNews import dev.pinkroom.marketsight.domain.use_case.news.SubscribeNews import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject @@ -32,7 +36,11 @@ class NewsViewModel @Inject constructor( private val _uiState = MutableStateFlow(NewsUiState()) val uiState = _uiState.asStateFlow() + private val _action = Channel() + val action = _action.receiveAsFlow() + private var initNewsJob: Job? = null + private var connectionStatus = Unavailable init { observeNetworkStatus() @@ -42,12 +50,14 @@ class NewsViewModel @Inject constructor( fun onEvent(event: NewsEvent){ when(event){ NewsEvent.RetryNews -> retryToGetNews() + NewsEvent.RefreshNews -> refreshNews() } } private fun observeNetworkStatus(){ viewModelScope.launch(dispatchers.IO) { connectivityObserver.observe().distinctUntilChanged().collect{ statusNet -> + connectionStatus = statusNet if (statusNet == Available && uiState.value.news.isEmpty() && initNewsJob == null) initNews() } @@ -60,19 +70,28 @@ class NewsViewModel @Inject constructor( when(val response = getNews()){ is Resource.Success -> { val allNews = response.data.news - val maxNumberNews = if (allNews.size >= 5) 5 else allNews.size - val mainNews = allNews.subList(fromIndex = 0, toIndex = maxNumberNews) + val maxNumberNews = if (allNews.size >= MAX_ITEMS_CAROUSEL) MAX_ITEMS_CAROUSEL else allNews.size + val mainNews = allNews.take(maxNumberNews) + val remainingNews = if (maxNumberNews == allNews.size) mainNews + else allNews.drop(maxNumberNews) _uiState.update { it.copy( - news = response.data.news, mainNews = mainNews, - isLoading = false, errorMessage = null, + news = remainingNews, mainNews = mainNews, + isLoading = false, errorMessage = null, isRefreshing = false, ) } } is Resource.Error -> { _uiState.update { - it.copy(isLoading = false, errorMessage = R.string.get_news_error_message) + it.copy( + isLoading = false, isRefreshing = false, + errorMessage = R.string.get_news_error_message + ) } + if (uiState.value.news.isNotEmpty()) + _action.send( + NewsAction.ShowSnackBar(message = R.string.get_news_error_message) + ) } } initNewsJob = null @@ -80,6 +99,13 @@ class NewsViewModel @Inject constructor( } private fun retryToGetNews() { - if (initNewsJob == null) initNews() + if (initNewsJob == null && connectionStatus == Available) initNews() + } + + private fun refreshNews() { + if (initNewsJob == null){ + _uiState.update { it.copy(isRefreshing = true) } + initNews() + } } } \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/AllNews.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/AllNews.kt new file mode 100644 index 0000000..a63c4c0 --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/AllNews.kt @@ -0,0 +1,67 @@ +package dev.pinkroom.marketsight.ui.news_screen.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import dev.pinkroom.marketsight.domain.model.news.NewsInfo +import dev.pinkroom.marketsight.ui.core.theme.dimens +import dev.pinkroom.marketsight.ui.core.theme.shimmerEffect + +fun LazyListScope.AllNews( + modifier: Modifier = Modifier, + isLoading: Boolean, + news: List, + navigateToNews: (newsInfo: NewsInfo) -> Unit, +){ + if (isLoading) + items(3){ + Row( + modifier = modifier + .fillMaxWidth() + .height(dimens.newsCard) + .padding(horizontal = dimens.horizontalPadding, vertical = dimens.smallPadding), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .shadow( + elevation = dimens.normalElevation, + shape = RoundedCornerShape(size = dimens.normalShape) + ) + .weight(0.5f) + .fillMaxSize() + .shimmerEffect() + ) + Box( + modifier = Modifier + .weight(1f) + .padding(start = dimens.smallPadding) + .fillMaxHeight() + .shimmerEffect() + ) + } + } + else + items(news){ item -> + AllNewsCard( + modifier = modifier + .fillMaxWidth() + .height(dimens.newsCard) + .padding(horizontal = dimens.horizontalPadding, vertical = dimens.smallPadding), + news = item, + onNewsClick = navigateToNews + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/AllNewsCard.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/AllNewsCard.kt new file mode 100644 index 0000000..015a721 --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/AllNewsCard.kt @@ -0,0 +1,121 @@ +package dev.pinkroom.marketsight.ui.news_screen.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import dev.pinkroom.marketsight.domain.model.news.ImageSize +import dev.pinkroom.marketsight.domain.model.news.ImagesNews +import dev.pinkroom.marketsight.domain.model.news.NewsInfo +import dev.pinkroom.marketsight.ui.core.theme.dimens +import java.time.LocalDateTime + +@Composable +fun AllNewsCard( + modifier: Modifier = Modifier, + news: NewsInfo, + onNewsClick: (news: NewsInfo) -> Unit, +){ + Row( + modifier = modifier + .clickable { + onNewsClick(news) + }, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + ImageNews( + modifier = Modifier + .shadow( + elevation = dimens.normalElevation, + shape = RoundedCornerShape(size = dimens.normalShape) + ) + .weight(0.5f) + .fillMaxSize(), + url = news.getImageUrl(imageSize = ImageSize.Thumb), + ) + Column( + modifier = Modifier + .weight(1f) + .padding(start = dimens.smallPadding) + .fillMaxHeight(), + verticalArrangement = Arrangement.SpaceEvenly, + ) { + if (news.symbols.isNotEmpty()) + Text( + text = news.getAllSymbols(), + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = news.headline, + style = MaterialTheme.typography.titleSmall, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + Row( + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = news.source, + style = MaterialTheme.typography.labelMedium, + ) + Text( + text = news.getUpdatedDateFormatted(), + style = MaterialTheme.typography.labelMedium, + ) + } + } + } +} + +@Preview( + showBackground = true, + showSystemUi = true, +) +@Composable +fun PreviewAllNewsCard(){ + AllNewsCard( + modifier = Modifier + .height(dimens.newsCard) + .fillMaxWidth() + .padding(horizontal = dimens.horizontalPadding), + news = NewsInfo( + id = 1L, + symbols = listOf("TSLA","AAPL","BTC"), + images = listOf( + ImagesNews( + size = ImageSize.Small, + url = "https://cdn.benzinga.com/files/imagecache/1024x768xUP/market-clubhouse-morning-memo_201.png", + ) + ), + source = "Bezinga", + url = "https://www.benzinga.com/", + updatedAt = LocalDateTime.now(), + createdAt = LocalDateTime.now(), + author = "RIPS", + summary = "Good Morning Traders! In today's Market Clubhouse Morning Memo, we will discuss SPY, QQQ, AAPL, MSFT, NVDA, GOOGL, META, and TSLA.", + headline = "rning Traders! In today's Market Clubhouse Morning Memo, we will discuss SPY, QQQ, AAPL, MSFT, Nrning Traders! In today's Market Clubhouse Morning Memo, we will discuss SPY, QQQ, AAPL, MSFT, Nrning Traders! In today's Market Clubhouse Morning Memo, we will discuss SPY, QQQ, AAPL, MSFT, NMarket Clubhouse Morning Memo - April 11th, 2024 (Trade Strategy For SPY, QQQ, AAPL, MSFT, NVDA, GOOGL, META, And TSLA)", + ), + onNewsClick = {}, + ) +} diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/EmptyNewsList.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/EmptyNewsList.kt index b455a58..a6b0c72 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/EmptyNewsList.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/EmptyNewsList.kt @@ -17,13 +17,12 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import dev.pinkroom.marketsight.R import dev.pinkroom.marketsight.ui.core.theme.dimens -import dev.pinkroom.marketsight.ui.news_screen.NewsEvent @Composable fun EmptyNewsList( modifier: Modifier = Modifier, errorMessage: Int, - onEvent: (event: NewsEvent) -> Unit, + onRetry: () -> Unit, ){ Column( modifier = modifier @@ -43,7 +42,7 @@ fun EmptyNewsList( ) Spacer(modifier = Modifier.height(dimens.normalPadding)) Button( - onClick = { onEvent(NewsEvent.RetryNews) } + onClick = onRetry ) { Text( text = stringResource(id = R.string.retry) diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/ImageNews.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/ImageNews.kt new file mode 100644 index 0000000..7e86464 --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/ImageNews.kt @@ -0,0 +1,41 @@ +package dev.pinkroom.marketsight.ui.news_screen.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import coil.compose.SubcomposeAsyncImage +import dev.pinkroom.marketsight.R +import dev.pinkroom.marketsight.ui.core.theme.shimmerEffect + +@Composable +fun ImageNews( + modifier: Modifier = Modifier, + url: String? = null, +){ + SubcomposeAsyncImage( + model = url, + contentDescription = null, + error = { + Image( + painter = painterResource(id = R.drawable.default_news_thumb), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + ) + }, + loading = { + Box( + modifier = Modifier + .fillMaxSize() + .shimmerEffect() + ) + }, + contentScale = ContentScale.Crop, + modifier = modifier + ) +} \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/MainNews.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/MainNews.kt index da9e171..837bcd0 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/MainNews.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/MainNews.kt @@ -49,7 +49,7 @@ fun MainNews( val isDragged by pagerState.interactionSource.collectIsDraggedAsState() var currentPageKey by remember { mutableIntStateOf(pagerState.currentPage) } - if (isDragged.not() && newsList.isNotEmpty()) { + if (isDragged.not() && newsList.isNotEmpty() && !isLoading) { with(pagerState) { LaunchedEffect(key1 = currentPageKey) { launch { diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/MainNewsCard.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/MainNewsCard.kt index e7ca306..ceed469 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/MainNewsCard.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/MainNewsCard.kt @@ -1,6 +1,5 @@ package dev.pinkroom.marketsight.ui.news_screen.components -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -24,16 +23,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.IntSize -import coil.compose.SubcomposeAsyncImage -import dev.pinkroom.marketsight.R import dev.pinkroom.marketsight.domain.model.news.ImageSize import dev.pinkroom.marketsight.domain.model.news.ImagesNews import dev.pinkroom.marketsight.domain.model.news.NewsInfo @@ -41,7 +36,6 @@ import dev.pinkroom.marketsight.domain.model.news.getAspectRatio import dev.pinkroom.marketsight.ui.core.theme.Black import dev.pinkroom.marketsight.ui.core.theme.White import dev.pinkroom.marketsight.ui.core.theme.dimens -import dev.pinkroom.marketsight.ui.core.theme.shimmerEffect import java.time.LocalDateTime @Composable @@ -66,32 +60,10 @@ fun MainNewsCard( onClick = { onClick(news) } ), ) { - SubcomposeAsyncImage( - model = news.getImageUrl(imageSize = dimens.imageSizeMainNews), - contentDescription = null, - error = { - val imageToLoad = when (dimens.imageSizeMainNews) { - ImageSize.Large -> R.drawable.default_news_large - ImageSize.Small -> R.drawable.default_news_small - ImageSize.Thumb -> R.drawable.default_news_thumb - } - Image( - painter = painterResource(id = imageToLoad), - contentDescription = null, - modifier = Modifier - .fillMaxSize() - ) - }, - loading = { - Box( - modifier = Modifier - .fillMaxSize() - .shimmerEffect() - ) - }, - contentScale = ContentScale.Crop, + ImageNews( modifier = Modifier - .fillMaxSize() + .fillMaxSize(), + url = news.getImageUrl(imageSize = dimens.imageSizeMainNews), ) Box( modifier = Modifier diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7ce213a..84a9cb9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3,6 +3,6 @@ News Home icon bottom bar - Something went wrong, check your internet connection. + Something went wrong when fetching the news. Check your Internet connection. Retry \ No newline at end of file From 0b8c08507292220345ffc152e8f73a6744fe6ae8 Mon Sep 17 00:00:00 2001 From: LucasPrioste Date: Mon, 22 Apr 2024 09:56:14 +0100 Subject: [PATCH 18/24] - real time news - refactor news repository and news remote source --- app/build.gradle.kts | 4 + .../pinkroom/marketsight/common/Constants.kt | 1 + .../ConnectivityObserver.kt | 2 +- .../NetworkConnectivityObserver.kt | 2 +- .../data/data_source/NewsRemoteDataSource.kt | 49 +++----- .../data/repository/NewsRepositoryImp.kt | 5 - .../dev/pinkroom/marketsight/di/AppModule.kt | 4 +- .../domain/repository/NewsRepository.kt | 7 +- .../domain/use_case/news/SubscribeNews.kt | 13 -- .../marketsight/ui/core/theme/Dimensions.kt | 4 +- .../marketsight/ui/news_screen/NewsScreen.kt | 24 ++++ .../ui/news_screen/NewsViewModel.kt | 17 ++- .../ui/news_screen/components/AllNews.kt | 19 ++- .../ui/news_screen/components/RealTimeNews.kt | 73 ++++++++++++ .../components/RealTimeNewsCard.kt | 112 ++++++++++++++++++ app/src/main/res/values/strings.xml | 2 + gradle/libs.versions.toml | 5 + 17 files changed, 277 insertions(+), 66 deletions(-) rename app/src/main/java/dev/pinkroom/marketsight/common/{ => connection_network}/ConnectivityObserver.kt (75%) rename app/src/main/java/dev/pinkroom/marketsight/common/{ => connection_network}/NetworkConnectivityObserver.kt (96%) delete mode 100644 app/src/main/java/dev/pinkroom/marketsight/domain/use_case/news/SubscribeNews.kt create mode 100644 app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/RealTimeNews.kt create mode 100644 app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/RealTimeNewsCard.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a2d2aa1..6e9cf7e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -109,6 +109,10 @@ dependencies { // SPLASH implementation(libs.splash) + // PAGING + implementation(libs.paging.runtime) + implementation(libs.paging.compose) + // COIL implementation(libs.coil) diff --git a/app/src/main/java/dev/pinkroom/marketsight/common/Constants.kt b/app/src/main/java/dev/pinkroom/marketsight/common/Constants.kt index 529f4d0..30da4a2 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/common/Constants.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/common/Constants.kt @@ -3,4 +3,5 @@ package dev.pinkroom.marketsight.common object Constants { const val ANIM_TIME_CAROUSEL = 700 const val MAX_ITEMS_CAROUSEL = 5 + const val LIMIT_NEWS = 20 } \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/common/ConnectivityObserver.kt b/app/src/main/java/dev/pinkroom/marketsight/common/connection_network/ConnectivityObserver.kt similarity index 75% rename from app/src/main/java/dev/pinkroom/marketsight/common/ConnectivityObserver.kt rename to app/src/main/java/dev/pinkroom/marketsight/common/connection_network/ConnectivityObserver.kt index e0be426..08aae22 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/common/ConnectivityObserver.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/common/connection_network/ConnectivityObserver.kt @@ -1,4 +1,4 @@ -package dev.pinkroom.marketsight.common +package dev.pinkroom.marketsight.common.connection_network import kotlinx.coroutines.flow.Flow diff --git a/app/src/main/java/dev/pinkroom/marketsight/common/NetworkConnectivityObserver.kt b/app/src/main/java/dev/pinkroom/marketsight/common/connection_network/NetworkConnectivityObserver.kt similarity index 96% rename from app/src/main/java/dev/pinkroom/marketsight/common/NetworkConnectivityObserver.kt rename to app/src/main/java/dev/pinkroom/marketsight/common/connection_network/NetworkConnectivityObserver.kt index a1b9a35..781fe26 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/common/NetworkConnectivityObserver.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/common/connection_network/NetworkConnectivityObserver.kt @@ -1,4 +1,4 @@ -package dev.pinkroom.marketsight.common +package dev.pinkroom.marketsight.common.connection_network import android.content.Context import android.net.ConnectivityManager diff --git a/app/src/main/java/dev/pinkroom/marketsight/data/data_source/NewsRemoteDataSource.kt b/app/src/main/java/dev/pinkroom/marketsight/data/data_source/NewsRemoteDataSource.kt index b76e9fd..e87d7e6 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/data/data_source/NewsRemoteDataSource.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/data/data_source/NewsRemoteDataSource.kt @@ -1,23 +1,21 @@ package dev.pinkroom.marketsight.data.data_source -import android.util.Log import com.google.gson.Gson -import com.tinder.scarlet.Message import com.tinder.scarlet.WebSocket import dev.pinkroom.marketsight.common.ActionAlpaca +import dev.pinkroom.marketsight.common.Constants import dev.pinkroom.marketsight.common.DispatcherProvider import dev.pinkroom.marketsight.common.HelperIdentifierMessagesAlpacaService import dev.pinkroom.marketsight.common.Resource import dev.pinkroom.marketsight.common.SortType import dev.pinkroom.marketsight.common.toObject -import dev.pinkroom.marketsight.common.verifyIfIsError -import dev.pinkroom.marketsight.data.mapper.toErrorMessage import dev.pinkroom.marketsight.data.remote.AlpacaNewsApi import dev.pinkroom.marketsight.data.remote.AlpacaNewsService import dev.pinkroom.marketsight.data.remote.model.dto.alpaca_news_api.NewsResponseDto import dev.pinkroom.marketsight.data.remote.model.dto.alpaca_news_service.NewsMessageDto import dev.pinkroom.marketsight.data.remote.model.dto.alpaca_news_service.SubscriptionMessageDto import dev.pinkroom.marketsight.data.remote.model.request.MessageAlpacaService +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.take @@ -29,31 +27,24 @@ class NewsRemoteDataSource @Inject constructor( private val alpacaNewsApi: AlpacaNewsApi, private val dispatchers: DispatcherProvider, ) { - fun subscribeNews(symbols: List = listOf("*")) = flow> { - alpacaNewsService.observeOnConnectionEvent().collect{ - when(it){ - is WebSocket.Event.OnConnectionOpened<*> -> { - alpacaNewsService.sendMessage(message = MessageAlpacaService(action = ActionAlpaca.Subscribe.action, news = symbols)) - } - is WebSocket.Event.OnMessageReceived -> { - Log.d(TAG,"Received: ${it.message}") - if (it.message is Message.Text){ - gson.verifyIfIsError(jsonValue = (it.message as Message.Text).value)?.let { errorMessage -> - emit(Resource.Error(message = errorMessage.msg, errorInfo = errorMessage.toErrorMessage())) - } ?: run { - emit(Resource.Success(it)) - } - } - } - is WebSocket.Event.OnConnectionFailed -> { - emit(Resource.Error(message = it.throwable.message, data = it)) - } - else -> Log.d(TAG,it.toString()) + private var isNewsSubscribed: Boolean = false + + private suspend fun subscribeNews(symbols: List = listOf("*")){ + alpacaNewsService.observeOnConnectionEvent() + .filter { it is WebSocket.Event.OnConnectionOpened<*> } + .take(1) + .collect{ + isNewsSubscribed = true + alpacaNewsService.sendMessage( + message = MessageAlpacaService( + action = ActionAlpaca.Subscribe.action, news = symbols, + ) + ) } - } - }.flowOn(dispatchers.IO) + } fun getRealTimeNews() = flow { + if (!isNewsSubscribed) subscribeNews() alpacaNewsService.observeResponse().collect{ data -> val listNews = mutableListOf() data.forEach { @@ -80,7 +71,7 @@ class NewsRemoteDataSource @Inject constructor( suspend fun getNews( symbols: List?, - limit: Int?, + limit: Int? = Constants.LIMIT_NEWS, pageToken: String?, sort: SortType? ): NewsResponseDto { @@ -91,8 +82,4 @@ class NewsRemoteDataSource @Inject constructor( sort = sort?.type, ) } - - companion object { - const val TAG = "AlpacaRemote" - } } \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/data/repository/NewsRepositoryImp.kt b/app/src/main/java/dev/pinkroom/marketsight/data/repository/NewsRepositoryImp.kt index 176ed11..50a5efb 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/data/repository/NewsRepositoryImp.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/data/repository/NewsRepositoryImp.kt @@ -19,11 +19,6 @@ class NewsRepositoryImp @Inject constructor( private val newsRemoteDataSource: NewsRemoteDataSource, private val dispatchers: DispatcherProvider, ): NewsRepository { - override fun subscribeNews(symbols: List) = flow { - newsRemoteDataSource.subscribeNews(symbols = symbols).collect{ - emit(it) - } - }.flowOn(dispatchers.IO) override fun getRealTimeNews() = flow { newsRemoteDataSource.getRealTimeNews().collect{ diff --git a/app/src/main/java/dev/pinkroom/marketsight/di/AppModule.kt b/app/src/main/java/dev/pinkroom/marketsight/di/AppModule.kt index 8f6881b..3d48ad5 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/di/AppModule.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/di/AppModule.kt @@ -14,13 +14,13 @@ import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import dev.pinkroom.marketsight.BuildConfig import dev.pinkroom.marketsight.MarketSightApp -import dev.pinkroom.marketsight.common.ConnectivityObserver import dev.pinkroom.marketsight.common.DefaultDispatchers import dev.pinkroom.marketsight.common.DispatcherProvider import dev.pinkroom.marketsight.common.FlowStreamAdapterFactory -import dev.pinkroom.marketsight.common.NetworkConnectivityObserver import dev.pinkroom.marketsight.common.addAuthenticationInterceptor import dev.pinkroom.marketsight.common.addLoggingInterceptor +import dev.pinkroom.marketsight.common.connection_network.ConnectivityObserver +import dev.pinkroom.marketsight.common.connection_network.NetworkConnectivityObserver import dev.pinkroom.marketsight.data.data_source.NewsRemoteDataSource import dev.pinkroom.marketsight.data.remote.AlpacaNewsApi import dev.pinkroom.marketsight.data.remote.AlpacaNewsService diff --git a/app/src/main/java/dev/pinkroom/marketsight/domain/repository/NewsRepository.kt b/app/src/main/java/dev/pinkroom/marketsight/domain/repository/NewsRepository.kt index dd2f56c..dfb6354 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/domain/repository/NewsRepository.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/domain/repository/NewsRepository.kt @@ -1,7 +1,7 @@ package dev.pinkroom.marketsight.domain.repository -import com.tinder.scarlet.WebSocket import dev.pinkroom.marketsight.common.ActionAlpaca +import dev.pinkroom.marketsight.common.Constants.LIMIT_NEWS import dev.pinkroom.marketsight.common.Resource import dev.pinkroom.marketsight.common.SortType import dev.pinkroom.marketsight.domain.model.news.NewsInfo @@ -9,9 +9,6 @@ import dev.pinkroom.marketsight.domain.model.news.NewsResponse import kotlinx.coroutines.flow.Flow interface NewsRepository { - fun subscribeNews( - symbols: List = listOf("*"), - ): Flow> fun getRealTimeNews(): Flow> suspend fun changeFilterNews( symbols: List, @@ -20,7 +17,7 @@ interface NewsRepository { suspend fun getNews( symbols: List? = null, - limit: Int? = null, + limit: Int? = LIMIT_NEWS, pageToken: String? = null, sort: SortType? = SortType.DESC, ): Resource diff --git a/app/src/main/java/dev/pinkroom/marketsight/domain/use_case/news/SubscribeNews.kt b/app/src/main/java/dev/pinkroom/marketsight/domain/use_case/news/SubscribeNews.kt deleted file mode 100644 index 68b70b4..0000000 --- a/app/src/main/java/dev/pinkroom/marketsight/domain/use_case/news/SubscribeNews.kt +++ /dev/null @@ -1,13 +0,0 @@ -package dev.pinkroom.marketsight.domain.use_case.news - -import dev.pinkroom.marketsight.common.DispatcherProvider -import dev.pinkroom.marketsight.domain.repository.NewsRepository -import kotlinx.coroutines.flow.flowOn -import javax.inject.Inject - -class SubscribeNews @Inject constructor( - private val newsRepository: NewsRepository, - private val dispatchers: DispatcherProvider, -){ - operator fun invoke() = newsRepository.subscribeNews().flowOn(dispatchers.IO) -} \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Dimensions.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Dimensions.kt index 8367c8b..356f185 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Dimensions.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Dimensions.kt @@ -58,7 +58,9 @@ class Dimensions( val circlePageIndicatorSize: Dp = 9.dp, val spaceBetweenPageIndicator: Dp = 2.dp, val newsCard: Dp = 120.dp, - val menuHeight: Dp = 100.dp + val menuHeight: Dp = 100.dp, + val liveNewsCardWidth: Dp = 270.dp, + val liveNewsCardHeight: Dp = 100.dp, ) val dimens: Dimensions diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsScreen.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsScreen.kt index dbce9d0..2529791 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsScreen.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsScreen.kt @@ -20,6 +20,7 @@ import dev.pinkroom.marketsight.ui.core.theme.dimens import dev.pinkroom.marketsight.ui.news_screen.components.AllNews import dev.pinkroom.marketsight.ui.news_screen.components.EmptyNewsList import dev.pinkroom.marketsight.ui.news_screen.components.MainNews +import dev.pinkroom.marketsight.ui.news_screen.components.RealTimeNews import java.time.LocalDateTime @Composable @@ -74,6 +75,17 @@ fun NewsScreen( } ) } + item { + RealTimeNews( + modifier = Modifier + .fillMaxWidth(), + news = realTimeNews, + isLoading = isLoading, + onNewsClick = { + context.navigateToNews(newsInfo = it) + } + ) + } AllNews( news = news, isLoading = isLoading, @@ -182,6 +194,18 @@ fun NewsScreenPreview(){ summary = "Good Morning Traders! In today's Market Clubhouse Morning Memo, we will discuss SPY, QQQ, AAPL, MSFT, NVDA, GOOGL, META, and TSLA.", headline = "Market Clubhouse Morning Memo - April 11th, 2024 (Trade Strategy For SPY, QQQ, AAPL, MSFT, NVDA, GOOGL, META, And TSLA)", ), + NewsInfo( + id = 2L, + symbols = listOf("TSLA","AAPL","BTC"), + images = null, + source = "Bezinga", + url = "https://www.benzinga.com/", + updatedAt = LocalDateTime.now(), + createdAt = LocalDateTime.now(), + author = "RIPS", + summary = "Good Morning Traders! In today's Market Clubhouse Morning Memo, we will discuss SPY, QQQ, AAPL, MSFT, NVDA, GOOGL, META, and TSLA.", + headline = "Market Clubhouse Morning Memo - April 11th, 2024 (Trade Strategy For SPY, QQQ, AAPL, MSFT, NVDA, GOOGL, META, And TSLA)", + ), ), symbols = listOf( SubInfoSymbols( diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsViewModel.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsViewModel.kt index 65b4fc8..c0c4cbf 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsViewModel.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsViewModel.kt @@ -4,16 +4,15 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import dev.pinkroom.marketsight.R -import dev.pinkroom.marketsight.common.ConnectivityObserver -import dev.pinkroom.marketsight.common.ConnectivityObserver.Status.Available -import dev.pinkroom.marketsight.common.ConnectivityObserver.Status.Unavailable import dev.pinkroom.marketsight.common.Constants.MAX_ITEMS_CAROUSEL import dev.pinkroom.marketsight.common.DispatcherProvider import dev.pinkroom.marketsight.common.Resource +import dev.pinkroom.marketsight.common.connection_network.ConnectivityObserver +import dev.pinkroom.marketsight.common.connection_network.ConnectivityObserver.Status.Available +import dev.pinkroom.marketsight.common.connection_network.ConnectivityObserver.Status.Unavailable import dev.pinkroom.marketsight.domain.use_case.news.ChangeFilterNews import dev.pinkroom.marketsight.domain.use_case.news.GetNews import dev.pinkroom.marketsight.domain.use_case.news.GetRealTimeNews -import dev.pinkroom.marketsight.domain.use_case.news.SubscribeNews import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow @@ -26,7 +25,6 @@ import javax.inject.Inject @HiltViewModel class NewsViewModel @Inject constructor( - private val subscribeNews: SubscribeNews, private val getRealTimeNews: GetRealTimeNews, private val getNews: GetNews, private val changeFilterNews: ChangeFilterNews, @@ -45,6 +43,7 @@ class NewsViewModel @Inject constructor( init { observeNetworkStatus() initNews() + fetchRealTimeNews() } fun onEvent(event: NewsEvent){ @@ -108,4 +107,12 @@ class NewsViewModel @Inject constructor( initNews() } } + + private fun fetchRealTimeNews() { + viewModelScope.launch(dispatchers.IO) { + getRealTimeNews().collect{ news -> + _uiState.update { it.copy(realTimeNews = it.realTimeNews + news) } + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/AllNews.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/AllNews.kt index a63c4c0..90b327a 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/AllNews.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/AllNews.kt @@ -11,9 +11,14 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.shadow +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import dev.pinkroom.marketsight.R import dev.pinkroom.marketsight.domain.model.news.NewsInfo import dev.pinkroom.marketsight.ui.core.theme.dimens import dev.pinkroom.marketsight.ui.core.theme.shimmerEffect @@ -53,8 +58,17 @@ fun LazyListScope.AllNews( ) } } - else - items(news){ item -> + else { + item { + Text( + modifier = Modifier + .padding(horizontal = dimens.horizontalPadding), + text = stringResource(id = R.string.latest_news), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + } + items(news) { item -> AllNewsCard( modifier = modifier .fillMaxWidth() @@ -64,4 +78,5 @@ fun LazyListScope.AllNews( onNewsClick = navigateToNews ) } + } } \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/RealTimeNews.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/RealTimeNews.kt new file mode 100644 index 0000000..3ad3e42 --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/RealTimeNews.kt @@ -0,0 +1,73 @@ +package dev.pinkroom.marketsight.ui.news_screen.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.fadeIn +import androidx.compose.animation.slideInHorizontally +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import dev.pinkroom.marketsight.R +import dev.pinkroom.marketsight.domain.model.news.NewsInfo +import dev.pinkroom.marketsight.ui.core.theme.dimens + +@OptIn(ExperimentalAnimationApi::class) +@Composable +fun RealTimeNews( + modifier: Modifier = Modifier, + news: List, + isLoading: Boolean, + onNewsClick: (news: NewsInfo) -> Unit, +){ + AnimatedVisibility( + visible = news.isNotEmpty() && !isLoading, + enter = slideInHorizontally() + fadeIn() + ) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(dimens.smallPadding) + ) { + Text( + modifier = Modifier + .padding(horizontal = dimens.horizontalPadding), + text = stringResource(id = R.string.real_time), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + LazyRow( + modifier = Modifier + .fillMaxWidth(), + contentPadding = PaddingValues( + horizontal = dimens.horizontalPadding, + ), + ) { + items( + items = news, + ){ + RealTimeNewsCard( + modifier = Modifier + .width(dimens.liveNewsCardWidth) + .height(dimens.liveNewsCardHeight) + .padding(vertical = dimens.smallPadding) + .padding(end = dimens.normalPadding), + news = it, + onClick = onNewsClick + ) + } + } + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/RealTimeNewsCard.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/RealTimeNewsCard.kt new file mode 100644 index 0000000..99b87e0 --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/RealTimeNewsCard.kt @@ -0,0 +1,112 @@ +package dev.pinkroom.marketsight.ui.news_screen.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredWidthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import dev.pinkroom.marketsight.domain.model.news.ImageSize +import dev.pinkroom.marketsight.domain.model.news.ImagesNews +import dev.pinkroom.marketsight.domain.model.news.NewsInfo +import dev.pinkroom.marketsight.ui.core.theme.dimens +import java.time.LocalDateTime + +@Composable +fun RealTimeNewsCard( + modifier: Modifier = Modifier, + news: NewsInfo, + onClick: (news: NewsInfo) -> Unit +){ + Card( + modifier = modifier + .clickable { onClick(news) } + .shadow( + elevation = dimens.lowElevation, + shape = RoundedCornerShape(size = dimens.normalShape) + ) + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(vertical = dimens.xSmallPadding, horizontal = dimens.smallPadding), + verticalArrangement = Arrangement.SpaceAround, + horizontalAlignment = Alignment.Start + ) { + if (news.symbols.isNotEmpty()) + Text( + text = news.getAllSymbols(), + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = news.headline, + style = MaterialTheme.typography.bodySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Row( + modifier = Modifier + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = news.source, + style = MaterialTheme.typography.labelSmall, + ) + Text( + text = news.getUpdatedDateFormatted(), + style = MaterialTheme.typography.labelSmall, + ) + } + } + } +} + + +@Preview( + showBackground = true, + showSystemUi = true, +) +@Composable +fun RealTimeNewsCardPreview(){ + RealTimeNewsCard( + modifier = Modifier + .requiredWidthIn(max = dimens.liveNewsCardWidth) + .height(dimens.liveNewsCardHeight), + news = NewsInfo( + id = 1L, + symbols = listOf("TSLA","AAPL","BTC"), + images = listOf( + ImagesNews( + size = ImageSize.Small, + url = "https://cdn.benzinga.com/files/imagecache/1024x768xUP/market-clubhouse-morning-memo_201.png", + ) + ), + source = "Bezinga", + url = "https://www.benzinga.com/", + updatedAt = LocalDateTime.now(), + createdAt = LocalDateTime.now(), + author = "RIPS", + summary = "Good Morning Traders! In today's Market Clubhouse Morning Memo, we will discuss SPY, QQQ, AAPL, MSFT, NVDA, GOOGL, META, and TSLA.", + headline = "Market Clubhouse Morning Memo - April 11th, 2024 (Trade Strategy For SPY, QQQ, AAPL, MSFT, NVDA, GOOGL, META, And TSLA)", + ), + onClick = {} + ) +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 84a9cb9..00afeed 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -5,4 +5,6 @@ icon bottom bar Something went wrong when fetching the news. Check your Internet connection. Retry + Live News + Latest News \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 156f73b..c820512 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,6 +21,7 @@ coroutineVersion = "1.8.0" assertkVersion = "0.28.0" fakerVersion = "1.0.2" coilVersion = "2.6.0" +pagingVersion = "3.2.1" [libraries] @@ -65,6 +66,10 @@ hilt-compiler = { group = "com.google.dagger", name = "hilt-compiler", version.r # SPLASH splash = { group = "androidx.core", name = "core-splashscreen", version.ref = "splashVersion" } +# PAGING +paging-runtime = { group = "androidx.paging", name = "paging-runtime", version.ref = "pagingVersion" } +paging-compose = { group = "androidx.paging", name = "paging-compose", version.ref = "pagingVersion" } + # COIL coil = { group = "io.coil-kt", name = "coil-compose", version.ref = "coilVersion" } From 80c43b2dd149c5667bd27f8054520dd9c87bce8e Mon Sep 17 00:00:00 2001 From: LucasPrioste Date: Mon, 22 Apr 2024 14:48:10 +0100 Subject: [PATCH 19/24] - pagination news - empty content ui --- .../pinkroom/marketsight/common/Constants.kt | 1 + .../common/paginator/DefaultPagination.kt | 43 ++++++++++ .../common/paginator/Pagination.kt | 6 ++ .../data/data_source/NewsRemoteDataSource.kt | 4 +- .../domain/model/common/PaginationInfo.kt | 7 ++ .../domain/use_case/news/GetNews.kt | 9 ++- .../ui/core/components/ReachedBottom.kt | 8 ++ .../ui/core/navigation/NavigationAppHost.kt | 1 + .../marketsight/ui/core/theme/Dimensions.kt | 1 + .../marketsight/ui/news_screen/NewsEvent.kt | 1 + .../marketsight/ui/news_screen/NewsScreen.kt | 30 ++++++- .../marketsight/ui/news_screen/NewsUiState.kt | 1 + .../ui/news_screen/NewsViewModel.kt | 37 +++++++++ .../ui/news_screen/components/AllNews.kt | 80 +++++++++++-------- .../news_screen/components/EmptyNewsList.kt | 20 ++--- .../ui/news_screen/components/MainNews.kt | 2 +- .../ui/news_screen/components/RealTimeNews.kt | 2 - app/src/main/res/values/strings.xml | 1 + 18 files changed, 203 insertions(+), 51 deletions(-) create mode 100644 app/src/main/java/dev/pinkroom/marketsight/common/paginator/DefaultPagination.kt create mode 100644 app/src/main/java/dev/pinkroom/marketsight/common/paginator/Pagination.kt create mode 100644 app/src/main/java/dev/pinkroom/marketsight/domain/model/common/PaginationInfo.kt create mode 100644 app/src/main/java/dev/pinkroom/marketsight/ui/core/components/ReachedBottom.kt diff --git a/app/src/main/java/dev/pinkroom/marketsight/common/Constants.kt b/app/src/main/java/dev/pinkroom/marketsight/common/Constants.kt index 30da4a2..70a51db 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/common/Constants.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/common/Constants.kt @@ -4,4 +4,5 @@ object Constants { const val ANIM_TIME_CAROUSEL = 700 const val MAX_ITEMS_CAROUSEL = 5 const val LIMIT_NEWS = 20 + const val BUFFER_LIST = 5 } \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/common/paginator/DefaultPagination.kt b/app/src/main/java/dev/pinkroom/marketsight/common/paginator/DefaultPagination.kt new file mode 100644 index 0000000..01d78e0 --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/common/paginator/DefaultPagination.kt @@ -0,0 +1,43 @@ +package dev.pinkroom.marketsight.common.paginator + +import dev.pinkroom.marketsight.common.Resource + +class DefaultPagination( + private val initialKey: Key?, + private inline val onLoadUpdated: (Boolean) -> Unit, + private inline val onRequest: suspend (nextKey: Key?) -> Resource, + private inline val getNextKey: suspend (T) -> Key, + private inline val onError: suspend (message: String?) -> Unit, + private inline val onSuccess: suspend (data: T, newKey: Key?) -> Unit +): Pagination { + + private var currentKey = initialKey + private var isMakingRequest = false + + override suspend fun loadNextItems() { + if(isMakingRequest) { + return + } + setLoading(isLoading = true) + when(val response = onRequest(currentKey)){ + is Resource.Error -> { + onError(response.message) + setLoading(isLoading = false) + } + is Resource.Success -> { + currentKey = getNextKey(response.data) + onSuccess(response.data, currentKey) + setLoading(isLoading = false) + } + } + } + + private fun setLoading(isLoading: Boolean){ + onLoadUpdated(isLoading) + isMakingRequest = isLoading + } + + override fun reset(key: Key?) { + currentKey = key ?: initialKey + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/common/paginator/Pagination.kt b/app/src/main/java/dev/pinkroom/marketsight/common/paginator/Pagination.kt new file mode 100644 index 0000000..0bc0eba --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/common/paginator/Pagination.kt @@ -0,0 +1,6 @@ +package dev.pinkroom.marketsight.common.paginator + +interface Pagination { + suspend fun loadNextItems() + fun reset(key: Key?) +} \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/data/data_source/NewsRemoteDataSource.kt b/app/src/main/java/dev/pinkroom/marketsight/data/data_source/NewsRemoteDataSource.kt index e87d7e6..c323c5d 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/data/data_source/NewsRemoteDataSource.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/data/data_source/NewsRemoteDataSource.kt @@ -70,10 +70,10 @@ class NewsRemoteDataSource @Inject constructor( }.flowOn(dispatchers.IO).take(1) suspend fun getNews( - symbols: List?, + symbols: List? = null, limit: Int? = Constants.LIMIT_NEWS, pageToken: String?, - sort: SortType? + sort: SortType? = null ): NewsResponseDto { return alpacaNewsApi.getNews( symbols = symbols?.joinToString(","), diff --git a/app/src/main/java/dev/pinkroom/marketsight/domain/model/common/PaginationInfo.kt b/app/src/main/java/dev/pinkroom/marketsight/domain/model/common/PaginationInfo.kt new file mode 100644 index 0000000..11e105c --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/domain/model/common/PaginationInfo.kt @@ -0,0 +1,7 @@ +package dev.pinkroom.marketsight.domain.model.common + +data class PaginationInfo( + val isLoading: Boolean = false, + val endReached: Boolean = false, + val page: String? = null +) \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/domain/use_case/news/GetNews.kt b/app/src/main/java/dev/pinkroom/marketsight/domain/use_case/news/GetNews.kt index 852c9aa..431d008 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/domain/use_case/news/GetNews.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/domain/use_case/news/GetNews.kt @@ -1,10 +1,17 @@ package dev.pinkroom.marketsight.domain.use_case.news +import dev.pinkroom.marketsight.common.Constants.LIMIT_NEWS import dev.pinkroom.marketsight.domain.repository.NewsRepository import javax.inject.Inject class GetNews @Inject constructor( private val newsRepository: NewsRepository, ){ - suspend operator fun invoke() = newsRepository.getNews() + suspend operator fun invoke( + pageToken: String? = null, + limitPerPage: Int? = LIMIT_NEWS, + ) = newsRepository.getNews( + pageToken = pageToken, + limit = limitPerPage, + ) } \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/core/components/ReachedBottom.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/core/components/ReachedBottom.kt new file mode 100644 index 0000000..c26adad --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/core/components/ReachedBottom.kt @@ -0,0 +1,8 @@ +package dev.pinkroom.marketsight.ui.core.components + +import androidx.compose.foundation.lazy.LazyListState + +internal fun LazyListState.reachedBottom(buffer: Int = 1): Boolean { + val lastVisibleItem = this.layoutInfo.visibleItemsInfo.lastOrNull() + return lastVisibleItem?.index != 0 && lastVisibleItem?.index == this.layoutInfo.totalItemsCount - buffer +} \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/NavigationAppHost.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/NavigationAppHost.kt index d19468e..13574aa 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/NavigationAppHost.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/NavigationAppHost.kt @@ -59,6 +59,7 @@ fun NavigationAppHost( realTimeNews = uiState.realTimeNews, symbols = uiState.symbols, isLoading = uiState.isLoading, + isLoadingMoreNews = uiState.isLoadingMoreItems, isRefreshing = uiState.isRefreshing, errorMessage = uiState.errorMessage, onEvent = viewModel::onEvent diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Dimensions.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Dimensions.kt index 356f185..688f839 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Dimensions.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Dimensions.kt @@ -61,6 +61,7 @@ class Dimensions( val menuHeight: Dp = 100.dp, val liveNewsCardWidth: Dp = 270.dp, val liveNewsCardHeight: Dp = 100.dp, + val emptyContentMaxHeight: Float = 0.95f ) val dimens: Dimensions diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsEvent.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsEvent.kt index 0f66a54..44d545a 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsEvent.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsEvent.kt @@ -3,4 +3,5 @@ package dev.pinkroom.marketsight.ui.news_screen sealed class NewsEvent { data object RetryNews: NewsEvent() data object RefreshNews: NewsEvent() + data object LoadMoreNews: NewsEvent() } diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsScreen.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsScreen.kt index 2529791..0476867 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsScreen.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsScreen.kt @@ -6,16 +6,23 @@ import android.net.Uri import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import dev.pinkroom.marketsight.R +import dev.pinkroom.marketsight.common.Constants.BUFFER_LIST import dev.pinkroom.marketsight.domain.model.common.SubInfoSymbols import dev.pinkroom.marketsight.domain.model.news.ImageSize import dev.pinkroom.marketsight.domain.model.news.ImagesNews import dev.pinkroom.marketsight.domain.model.news.NewsInfo import dev.pinkroom.marketsight.ui.core.components.PullToRefreshLazyColumn +import dev.pinkroom.marketsight.ui.core.components.reachedBottom import dev.pinkroom.marketsight.ui.core.theme.dimens import dev.pinkroom.marketsight.ui.news_screen.components.AllNews import dev.pinkroom.marketsight.ui.news_screen.components.EmptyNewsList @@ -31,12 +38,21 @@ fun NewsScreen( realTimeNews: List, symbols: List, isLoading: Boolean, + isLoadingMoreNews: Boolean, isRefreshing: Boolean, errorMessage: Int? = null, onEvent: (event: NewsEvent) -> Unit, ){ val context = LocalContext.current + val listState = rememberLazyListState() + val reachedBottom: Boolean by remember { + derivedStateOf { listState.reachedBottom(buffer = BUFFER_LIST) } + } + LaunchedEffect(reachedBottom) { + if (reachedBottom && !isLoading && !isLoadingMoreNews) onEvent(NewsEvent.LoadMoreNews) + } + PullToRefreshLazyColumn( modifier = modifier .fillMaxSize(), @@ -51,13 +67,14 @@ fun NewsScreen( onRefresh = { onEvent(NewsEvent.RefreshNews) }, + lazyListState = listState, ) { if (!isLoading && errorMessage != null && news.isEmpty()){ item { EmptyNewsList( modifier = Modifier .fillParentMaxSize(), - errorMessage = errorMessage, + message = errorMessage, onRetry = { onEvent(NewsEvent.RetryNews) }, @@ -86,9 +103,19 @@ fun NewsScreen( } ) } + if (news.isEmpty() && !isLoading) + item { + EmptyNewsList( + modifier = Modifier + .fillMaxWidth() + .fillParentMaxHeight(dimens.emptyContentMaxHeight), + message = R.string.empty_news, + ) + } AllNews( news = news, isLoading = isLoading, + isLoadingMoreNews = isLoadingMoreNews, navigateToNews = { context.navigateToNews(newsInfo = it) } @@ -216,6 +243,7 @@ fun NewsScreenPreview(){ ), ), isLoading = false, + isLoadingMoreNews = false, errorMessage = R.string.get_news_error_message, isRefreshing = false, onEvent = {}, diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsUiState.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsUiState.kt index 638ceb3..36f2c2c 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsUiState.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsUiState.kt @@ -6,6 +6,7 @@ import dev.pinkroom.marketsight.domain.model.news.NewsInfo data class NewsUiState( val isLoading: Boolean = true, + val isLoadingMoreItems: Boolean = false, val isRefreshing: Boolean = false, @DrawableRes val errorMessage: Int? = null, val mainNews: List = listOf(), diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsViewModel.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsViewModel.kt index c0c4cbf..532d3ee 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsViewModel.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsViewModel.kt @@ -10,6 +10,9 @@ import dev.pinkroom.marketsight.common.Resource import dev.pinkroom.marketsight.common.connection_network.ConnectivityObserver import dev.pinkroom.marketsight.common.connection_network.ConnectivityObserver.Status.Available import dev.pinkroom.marketsight.common.connection_network.ConnectivityObserver.Status.Unavailable +import dev.pinkroom.marketsight.common.paginator.DefaultPagination +import dev.pinkroom.marketsight.domain.model.common.PaginationInfo +import dev.pinkroom.marketsight.domain.model.news.NewsResponse import dev.pinkroom.marketsight.domain.use_case.news.ChangeFilterNews import dev.pinkroom.marketsight.domain.use_case.news.GetNews import dev.pinkroom.marketsight.domain.use_case.news.GetRealTimeNews @@ -37,6 +40,31 @@ class NewsViewModel @Inject constructor( private val _action = Channel() val action = _action.receiveAsFlow() + private var paginationInfo = PaginationInfo() + private val pagination = DefaultPagination( + initialKey = null, + onLoadUpdated = { isLoading -> + paginationInfo = paginationInfo.copy(isLoading = isLoading) + _uiState.update { it.copy(isLoadingMoreItems = isLoading) } + }, + onRequest = { nextPage -> + getNews(pageToken = nextPage) + }, + getNextKey = { + it.nextPageToken + }, + onError = { + _action.send(NewsAction.ShowSnackBar(message = R.string.get_news_error_message)) + }, + onSuccess = { data, newKey -> + paginationInfo = paginationInfo.copy( + endReached = newKey == null, + page = newKey + ) + _uiState.update { it.copy(news = it.news + data.news) } + } + ) + private var initNewsJob: Job? = null private var connectionStatus = Unavailable @@ -50,6 +78,7 @@ class NewsViewModel @Inject constructor( when(event){ NewsEvent.RetryNews -> retryToGetNews() NewsEvent.RefreshNews -> refreshNews() + NewsEvent.LoadMoreNews -> loadMoreNews() } } @@ -73,6 +102,8 @@ class NewsViewModel @Inject constructor( val mainNews = allNews.take(maxNumberNews) val remainingNews = if (maxNumberNews == allNews.size) mainNews else allNews.drop(maxNumberNews) + + pagination.reset(key = response.data.nextPageToken) _uiState.update { it.copy( news = remainingNews, mainNews = mainNews, @@ -108,6 +139,12 @@ class NewsViewModel @Inject constructor( } } + private fun loadMoreNews(){ + viewModelScope.launch(dispatchers.IO) { + if (!paginationInfo.isLoading) pagination.loadNextItems() + } + } + private fun fetchRealTimeNews() { viewModelScope.launch(dispatchers.IO) { getRealTimeNews().collect{ news -> diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/AllNews.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/AllNews.kt index 90b327a..a83a464 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/AllNews.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/AllNews.kt @@ -26,47 +26,22 @@ import dev.pinkroom.marketsight.ui.core.theme.shimmerEffect fun LazyListScope.AllNews( modifier: Modifier = Modifier, isLoading: Boolean, + isLoadingMoreNews: Boolean, news: List, navigateToNews: (newsInfo: NewsInfo) -> Unit, ){ if (isLoading) - items(3){ - Row( - modifier = modifier - .fillMaxWidth() - .height(dimens.newsCard) - .padding(horizontal = dimens.horizontalPadding, vertical = dimens.smallPadding), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = Modifier - .shadow( - elevation = dimens.normalElevation, - shape = RoundedCornerShape(size = dimens.normalShape) - ) - .weight(0.5f) - .fillMaxSize() - .shimmerEffect() - ) - Box( - modifier = Modifier - .weight(1f) - .padding(start = dimens.smallPadding) - .fillMaxHeight() - .shimmerEffect() - ) - } - } + LoadingNews() else { item { - Text( - modifier = Modifier - .padding(horizontal = dimens.horizontalPadding), - text = stringResource(id = R.string.latest_news), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - ) + if (news.isNotEmpty()) + Text( + modifier = Modifier + .padding(horizontal = dimens.horizontalPadding), + text = stringResource(id = R.string.latest_news), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) } items(news) { item -> AllNewsCard( @@ -78,5 +53,40 @@ fun LazyListScope.AllNews( onNewsClick = navigateToNews ) } + if (isLoadingMoreNews) + LoadingNews() + } +} + +fun LazyListScope.LoadingNews( + modifier: Modifier = Modifier, +){ + items(3){ + Row( + modifier = modifier + .fillMaxWidth() + .height(dimens.newsCard) + .padding(horizontal = dimens.horizontalPadding, vertical = dimens.smallPadding), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .shadow( + elevation = dimens.normalElevation, + shape = RoundedCornerShape(size = dimens.normalShape) + ) + .weight(0.5f) + .fillMaxSize() + .shimmerEffect() + ) + Box( + modifier = Modifier + .weight(1f) + .padding(start = dimens.smallPadding) + .fillMaxHeight() + .shimmerEffect() + ) + } } } \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/EmptyNewsList.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/EmptyNewsList.kt index a6b0c72..9910814 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/EmptyNewsList.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/EmptyNewsList.kt @@ -21,8 +21,8 @@ import dev.pinkroom.marketsight.ui.core.theme.dimens @Composable fun EmptyNewsList( modifier: Modifier = Modifier, - errorMessage: Int, - onRetry: () -> Unit, + message: Int, + onRetry: (() -> Unit)? = null, ){ Column( modifier = modifier @@ -37,16 +37,18 @@ fun EmptyNewsList( ) Spacer(modifier = Modifier.height(dimens.smallPadding)) Text( - text = stringResource(id = errorMessage), + text = stringResource(id = message), textAlign = TextAlign.Center ) Spacer(modifier = Modifier.height(dimens.normalPadding)) - Button( - onClick = onRetry - ) { - Text( - text = stringResource(id = R.string.retry) - ) + if (onRetry != null) { + Button( + onClick = onRetry + ) { + Text( + text = stringResource(id = R.string.retry) + ) + } } } } \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/MainNews.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/MainNews.kt index 837bcd0..01953ab 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/MainNews.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/MainNews.kt @@ -79,7 +79,7 @@ fun MainNews( .aspectRatio(dimens.imageSizeMainNews.getAspectRatio()) .shimmerEffect() ) - } else { + } else if (newsList.isNotEmpty()) { Column( modifier = Modifier .fillMaxWidth(), diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/RealTimeNews.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/RealTimeNews.kt index 3ad3e42..da6bebc 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/RealTimeNews.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/RealTimeNews.kt @@ -1,7 +1,6 @@ package dev.pinkroom.marketsight.ui.news_screen.components import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.animation.fadeIn import androidx.compose.animation.slideInHorizontally import androidx.compose.foundation.layout.Arrangement @@ -23,7 +22,6 @@ import dev.pinkroom.marketsight.R import dev.pinkroom.marketsight.domain.model.news.NewsInfo import dev.pinkroom.marketsight.ui.core.theme.dimens -@OptIn(ExperimentalAnimationApi::class) @Composable fun RealTimeNews( modifier: Modifier = Modifier, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 00afeed..49f03f9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -7,4 +7,5 @@ Retry Live News Latest News + Unable to find news for selected filters. \ No newline at end of file From dd409caf6e782cefacf4aadb511ad1dca6ea263b Mon Sep 17 00:00:00 2001 From: LucasPrioste Date: Wed, 24 Apr 2024 17:29:43 +0100 Subject: [PATCH 20/24] - date picker ui - desc/asc ui - symbols subscribed ui --- .../pinkroom/marketsight/common/Constants.kt | 6 + .../dev/pinkroom/marketsight/common/Utils.kt | 102 +++++++- .../domain/model/common/SubInfoSymbols.kt | 3 + .../ui/core/components/ButtonFilter.kt | 64 +++++ .../ui/core/components/DatePickerComponent.kt | 115 +++++++++ .../core/components/DefaultSectionFilter.kt | 35 +++ .../ui/core/navigation/NavigationAppHost.kt | 11 +- .../marketsight/ui/core/theme/Color.kt | 4 +- .../marketsight/ui/core/theme/Dimensions.kt | 9 +- .../marketsight/ui/core/theme/Theme.kt | 2 + .../{components => util}/ObserveAsEvents.kt | 2 +- .../{components => util}/ReachedBottom.kt | 2 +- .../ui/core/util/SelectableDatesImp.kt | 28 ++ .../marketsight/ui/news_screen/NewsEvent.kt | 8 + .../marketsight/ui/news_screen/NewsScreen.kt | 102 ++++++-- .../marketsight/ui/news_screen/NewsUiState.kt | 16 +- .../ui/news_screen/NewsViewModel.kt | 72 +++++- .../components/BottomSheetFilters.kt | 241 ++++++++++++++++++ .../news_screen/components/HeaderListNews.kt | 53 ++++ .../ui/news_screen/components/RealTimeNews.kt | 61 ++++- app/src/main/res/drawable/icon_check.xml | 5 + .../main/res/drawable/icon_filter_list.xml | 5 + app/src/main/res/values/strings.xml | 13 + 23 files changed, 915 insertions(+), 44 deletions(-) create mode 100644 app/src/main/java/dev/pinkroom/marketsight/ui/core/components/ButtonFilter.kt create mode 100644 app/src/main/java/dev/pinkroom/marketsight/ui/core/components/DatePickerComponent.kt create mode 100644 app/src/main/java/dev/pinkroom/marketsight/ui/core/components/DefaultSectionFilter.kt rename app/src/main/java/dev/pinkroom/marketsight/ui/core/{components => util}/ObserveAsEvents.kt (91%) rename app/src/main/java/dev/pinkroom/marketsight/ui/core/{components => util}/ReachedBottom.kt (85%) create mode 100644 app/src/main/java/dev/pinkroom/marketsight/ui/core/util/SelectableDatesImp.kt create mode 100644 app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/BottomSheetFilters.kt create mode 100644 app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/HeaderListNews.kt create mode 100644 app/src/main/res/drawable/icon_check.xml create mode 100644 app/src/main/res/drawable/icon_filter_list.xml diff --git a/app/src/main/java/dev/pinkroom/marketsight/common/Constants.kt b/app/src/main/java/dev/pinkroom/marketsight/common/Constants.kt index 70a51db..953892c 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/common/Constants.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/common/Constants.kt @@ -1,8 +1,14 @@ package dev.pinkroom.marketsight.common object Constants { + // ANIM TIME const val ANIM_TIME_CAROUSEL = 700 + + // LIMIT const val MAX_ITEMS_CAROUSEL = 5 const val LIMIT_NEWS = 20 const val BUFFER_LIST = 5 + + // TEXT + const val ALL_SYMBOLS = "All" } \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/common/Utils.kt b/app/src/main/java/dev/pinkroom/marketsight/common/Utils.kt index e93c9db..797697d 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/common/Utils.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/common/Utils.kt @@ -1,8 +1,17 @@ package dev.pinkroom.marketsight.common +import androidx.annotation.StringRes import dev.pinkroom.marketsight.BuildConfig +import dev.pinkroom.marketsight.R +import dev.pinkroom.marketsight.common.Constants.ALL_SYMBOLS +import dev.pinkroom.marketsight.domain.model.common.SubInfoSymbols import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor +import java.time.LocalDate +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.util.Locale fun OkHttpClient.Builder.addAuthenticationInterceptor(): OkHttpClient.Builder { addInterceptor { chain -> @@ -26,12 +35,97 @@ fun OkHttpClient.Builder.addLoggingInterceptor(): OkHttpClient.Builder { return this } +fun LocalDate.toEpochMillis(zoneOffset: ZoneOffset = ZoneOffset.UTC, endOfTheDay: Boolean = false): Long{ + val time = if (endOfTheDay) atTime(23,59) else atStartOfDay() + return time.atOffset(zoneOffset).toInstant().toEpochMilli() +} +fun LocalDate.toReadableDate(): String { + val formatter = DateTimeFormatter + .ofLocalizedDate(FormatStyle.SHORT) + .withLocale(Locale.getDefault()) + return format(formatter) +} + sealed class ActionAlpaca(val action: String) { data object Subscribe: ActionAlpaca(action = "subscribe") data object Unsubscribe: ActionAlpaca(action = "unsubscribe") } -sealed class SortType(val type: String){ - data object DESC: SortType(type = "desc") - data object ASC: SortType(type = "asc") -} \ No newline at end of file +sealed class SortType(val type: String, @StringRes val stringId: Int){ + data object DESC: SortType(type = "desc", stringId = R.string.descending) + data object ASC: SortType(type = "asc", stringId = R.string.ascending) +} + +sealed interface DateMomentType{ + data object Start: DateMomentType + data object End: DateMomentType +} + +val popularSymbols = listOf( + SubInfoSymbols( + stringResource = R.string.all, + name = ALL_SYMBOLS, + symbol = "*", + isSubscribed = true, + ), + SubInfoSymbols( + name = "Tesla", + symbol = "TSLA", + isSubscribed = false, + ), + SubInfoSymbols( + name = "Apple", + symbol = "AAPL", + isSubscribed = false, + ), + SubInfoSymbols( + name = "Microsoft", + symbol = "MSFT", + isSubscribed = false, + ), + SubInfoSymbols( + name = "NVIDIA", + symbol = "NVDA", + isSubscribed = false, + ), + SubInfoSymbols( + name = "Alphabet", + symbol = "GOOG", + isSubscribed = false, + ), + SubInfoSymbols( + name = "Amazon", + symbol = "AMZN", + isSubscribed = false, + ), + SubInfoSymbols( + name = "Meta", + symbol = "META", + isSubscribed = false, + ), + SubInfoSymbols( + name = "Visa", + symbol = "V", + isSubscribed = false, + ), + SubInfoSymbols( + name = "Coca-Cola", + symbol = "KO", + isSubscribed = false, + ), + SubInfoSymbols( + name = "Bitcoin", + symbol = "BTC/USD", + isSubscribed = false, + ), + SubInfoSymbols( + name = "Ethereum", + symbol = "ETH/USD", + isSubscribed = false, + ), + SubInfoSymbols( + name = "Shiba", + symbol = "SHIB/USD", + isSubscribed = false, + ), +) \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/domain/model/common/SubInfoSymbols.kt b/app/src/main/java/dev/pinkroom/marketsight/domain/model/common/SubInfoSymbols.kt index a97d12e..04f8cb6 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/domain/model/common/SubInfoSymbols.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/domain/model/common/SubInfoSymbols.kt @@ -1,6 +1,9 @@ package dev.pinkroom.marketsight.domain.model.common +import androidx.annotation.StringRes + data class SubInfoSymbols( + @StringRes val stringResource: Int? = null, val name: String, val symbol: String, val isSubscribed: Boolean = false, diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/core/components/ButtonFilter.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/core/components/ButtonFilter.kt new file mode 100644 index 0000000..1da14ea --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/core/components/ButtonFilter.kt @@ -0,0 +1,64 @@ +package dev.pinkroom.marketsight.ui.core.components + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import dev.pinkroom.marketsight.R +import dev.pinkroom.marketsight.ui.core.theme.BabyBlue +import dev.pinkroom.marketsight.ui.core.theme.Blue +import dev.pinkroom.marketsight.ui.core.theme.dimens + +@Composable +fun ButtonFilter( + isSelected: Boolean, + text: String, + onClick: () -> Unit, +){ + val colorBackground = if (isSelected) BabyBlue.copy(alpha = 0.4f) else Color.Transparent + val colorContent = if (isSelected) Blue else MaterialTheme.colorScheme.onBackground + val colorBorder = if (isSelected) Color.Transparent else MaterialTheme.colorScheme.onBackground + Button( + shape = RoundedCornerShape(dimens.smallShape), + border = BorderStroke( + width = dimens.smallWidth, color = colorBorder, + ), + colors = ButtonDefaults.buttonColors( + containerColor = colorBackground, + contentColor = colorContent, + ), + onClick = onClick, + ) { + Row( + modifier = Modifier + .animateContentSize(), + horizontalArrangement = Arrangement.spacedBy(dimens.xSmallPadding), + verticalAlignment = Alignment.CenterVertically, + ) { + if (isSelected) + Icon( + modifier = Modifier + .size(dimens.smallIconSize), + painter = painterResource(id = R.drawable.icon_check), + contentDescription = null, + ) + + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/core/components/DatePickerComponent.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/core/components/DatePickerComponent.kt new file mode 100644 index 0000000..90af288 --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/core/components/DatePickerComponent.kt @@ -0,0 +1,115 @@ +package dev.pinkroom.marketsight.ui.core.components + +import android.annotation.SuppressLint +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.text.selection.TextSelectionColors +import androidx.compose.material3.Button +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDefaults +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.DatePickerState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.SelectableDates +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.stringResource +import dev.pinkroom.marketsight.R +import dev.pinkroom.marketsight.common.toEpochMillis +import dev.pinkroom.marketsight.ui.core.theme.BabyBlue +import dev.pinkroom.marketsight.ui.core.theme.Blue +import dev.pinkroom.marketsight.ui.core.theme.dimens +import java.time.LocalDate + +@SuppressLint("UnrememberedMutableState") +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DatePickerComponent( + modifier: Modifier = Modifier, + initialDate: LocalDate? = null, + isToShowDatePicker: Boolean, + saveNewDate: (date: Long?) -> Unit, + dismissDatePicker: () -> Unit, + selectableDates: SelectableDates, +){ + val locale = LocalConfiguration.current.locales[0] + + val state = DatePickerState( + locale = locale, + initialSelectedDateMillis = initialDate?.toEpochMillis(), + selectableDates = selectableDates, + ) + + val colorCurrentDay = if (selectableDates.isSelectableDate(System.currentTimeMillis())) + MaterialTheme.colorScheme.onBackground + else MaterialTheme.colorScheme.onBackground.copy(alpha = 0.4f) + + if (isToShowDatePicker) + DatePickerDialog( + modifier = modifier, + onDismissRequest = { + state.selectedDateMillis = initialDate?.toEpochMillis() + dismissDatePicker() + }, + confirmButton = { + Row( + horizontalArrangement = Arrangement.spacedBy(dimens.smallPadding), + ) { + AnimatedVisibility(visible = state.selectedDateMillis != null) { + Button( + onClick = { + state.selectedDateMillis = null + saveNewDate(null) + dismissDatePicker() + }, + ) { + Text(text = stringResource(id = R.string.clear_date)) + } + } + Button( + onClick = { + state.selectedDateMillis?.let { saveNewDate(it) } + dismissDatePicker() + }, + ) { + Text(text = stringResource(id = R.string.select)) + } + } + }, + dismissButton = { + Button( + onClick = { + state.selectedDateMillis = initialDate?.toEpochMillis() + dismissDatePicker() + }, + ) { + Text(text = stringResource(id = R.string.cancel)) + } + }, + ) { + DatePicker( + state = state, + colors = DatePickerDefaults.colors( + selectedDayContainerColor = BabyBlue.copy(alpha = 0.3f), + selectedDayContentColor = Blue, + todayContentColor = colorCurrentDay, + todayDateBorderColor = MaterialTheme.colorScheme.onBackground, + currentYearContentColor = MaterialTheme.colorScheme.onBackground, + dateTextFieldColors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.onBackground, + focusedLabelColor = MaterialTheme.colorScheme.onBackground, + cursorColor = MaterialTheme.colorScheme.onBackground, + selectionColors = TextSelectionColors( + handleColor = MaterialTheme.colorScheme.onBackground, + backgroundColor = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.4f) + ) + ) + ) + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/core/components/DefaultSectionFilter.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/core/components/DefaultSectionFilter.kt new file mode 100644 index 0000000..5005aed --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/core/components/DefaultSectionFilter.kt @@ -0,0 +1,35 @@ +package dev.pinkroom.marketsight.ui.core.components + +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import dev.pinkroom.marketsight.ui.core.theme.dimens + +@Composable +fun DefaultSectionFilter( + modifier: Modifier = Modifier, + title: String, + showBottomDivider: Boolean = false, + content: @Composable () -> Unit, +){ + HorizontalDivider(modifier = modifier.padding(horizontal = dimens.smallPadding)) + Text( + modifier = Modifier + .padding(horizontal = dimens.horizontalPadding) + .padding(top = dimens.smallPadding), + text = title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center, + ) + content() + if (showBottomDivider) + HorizontalDivider(modifier = modifier + .padding(horizontal = dimens.smallPadding, vertical = dimens.smallPadding) + ) +} \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/NavigationAppHost.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/NavigationAppHost.kt index 13574aa..0663688 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/NavigationAppHost.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/NavigationAppHost.kt @@ -1,5 +1,6 @@ package dev.pinkroom.marketsight.ui.core.navigation +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.SnackbarDuration import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -11,8 +12,8 @@ import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.navArgument -import dev.pinkroom.marketsight.ui.core.components.ObserveAsEvents import dev.pinkroom.marketsight.ui.core.navigation.Args.SYMBOL_ID +import dev.pinkroom.marketsight.ui.core.util.ObserveAsEvents import dev.pinkroom.marketsight.ui.detail_screen.DetailScreen import dev.pinkroom.marketsight.ui.home_screen.HomeScreen import dev.pinkroom.marketsight.ui.home_screen.HomeViewModel @@ -20,6 +21,7 @@ import dev.pinkroom.marketsight.ui.news_screen.NewsAction import dev.pinkroom.marketsight.ui.news_screen.NewsScreen import dev.pinkroom.marketsight.ui.news_screen.NewsViewModel +@OptIn(ExperimentalMaterial3Api::class) @Composable fun NavigationAppHost( modifier: Modifier = Modifier, @@ -58,9 +60,16 @@ fun NavigationAppHost( mainNews = uiState.mainNews, realTimeNews = uiState.realTimeNews, symbols = uiState.symbols, + sortBy = uiState.sortBy, + sortItems = uiState.sort, + endDate = uiState.endDateSort, + startDate = uiState.startDateSort, + startSelectableDates = uiState.startSelectableDates, + endSelectableDates = uiState.endSelectableDates, isLoading = uiState.isLoading, isLoadingMoreNews = uiState.isLoadingMoreItems, isRefreshing = uiState.isRefreshing, + isToShowFilters = uiState.isToShowFilters, errorMessage = uiState.errorMessage, onEvent = viewModel::onEvent ) diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Color.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Color.kt index 5d3b9c0..ae88b88 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Color.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Color.kt @@ -12,4 +12,6 @@ val Green = Color(0xFF3DD787) val Purple = Color(0xFF7F55E7) val Red = Color(0xFFE94237) val PhilippineSilver = Color(0xFFB8B5B5) -val PhilippineGray = Color(0xFF8F8B8B) \ No newline at end of file +val PhilippineGray = Color(0xFF8F8B8B) +val BabyBlue = Color(0xFF89CFF0) +val Blue = Color(0xFF318CE7) \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Dimensions.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Dimensions.kt index 688f839..9e56965 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Dimensions.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Dimensions.kt @@ -18,7 +18,7 @@ class Dimensions( val headlineSmall: TextUnit = 24.sp, val titleLarge: TextUnit = 22.sp, val titleMedium: TextUnit = 19.sp, - val titleSmall: TextUnit = 14.sp, + val titleSmall: TextUnit = 16.sp, val bodyLarge: TextUnit = 16.sp, val bodyMedium: TextUnit = 14.sp, val bodySmall: TextUnit = 12.sp, @@ -34,6 +34,7 @@ class Dimensions( val xLargePadding: Dp = 32.dp, // Icon + val smallIconSize: Dp = 16.dp, val normalIconSize: Dp = 24.dp, val largeIconSize: Dp = 38.dp, @@ -42,6 +43,9 @@ class Dimensions( val normalElevation: Dp = 9.dp, val largeElevation: Dp = 12.dp, + // Border Width + val smallWidth: Dp = 1.5.dp, + // Shape val smallShape: Dp = 10.dp, val normalShape: Dp = 15.dp, @@ -61,7 +65,8 @@ class Dimensions( val menuHeight: Dp = 100.dp, val liveNewsCardWidth: Dp = 270.dp, val liveNewsCardHeight: Dp = 100.dp, - val emptyContentMaxHeight: Float = 0.95f + val emptyContentMaxHeight: Float = 0.95f, + val bottomSheetHeight: Float = 0.45f, ) val dimens: Dimensions diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Theme.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Theme.kt index f4c80b1..78e4467 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Theme.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Theme.kt @@ -24,6 +24,7 @@ private val DarkColorScheme = darkColorScheme( background = WoodSmoke, onBackground = White, tertiaryContainer = Gray, + outline = GrayAthens, ) private val LightColorScheme = lightColorScheme( @@ -35,6 +36,7 @@ private val LightColorScheme = lightColorScheme( background = GrayAthens, onBackground = Black, tertiaryContainer = Black, + outline = Manatee, ) @Composable diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/core/components/ObserveAsEvents.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/core/util/ObserveAsEvents.kt similarity index 91% rename from app/src/main/java/dev/pinkroom/marketsight/ui/core/components/ObserveAsEvents.kt rename to app/src/main/java/dev/pinkroom/marketsight/ui/core/util/ObserveAsEvents.kt index a8e9d5e..d181753 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/core/components/ObserveAsEvents.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/core/util/ObserveAsEvents.kt @@ -1,4 +1,4 @@ -package dev.pinkroom.marketsight.ui.core.components +package dev.pinkroom.marketsight.ui.core.util import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/core/components/ReachedBottom.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/core/util/ReachedBottom.kt similarity index 85% rename from app/src/main/java/dev/pinkroom/marketsight/ui/core/components/ReachedBottom.kt rename to app/src/main/java/dev/pinkroom/marketsight/ui/core/util/ReachedBottom.kt index c26adad..c7305f1 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/core/components/ReachedBottom.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/core/util/ReachedBottom.kt @@ -1,4 +1,4 @@ -package dev.pinkroom.marketsight.ui.core.components +package dev.pinkroom.marketsight.ui.core.util import androidx.compose.foundation.lazy.LazyListState diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/core/util/SelectableDatesImp.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/core/util/SelectableDatesImp.kt new file mode 100644 index 0000000..17a8e3d --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/core/util/SelectableDatesImp.kt @@ -0,0 +1,28 @@ +package dev.pinkroom.marketsight.ui.core.util + +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SelectableDates +import dev.pinkroom.marketsight.common.DateMomentType +import dev.pinkroom.marketsight.common.toEpochMillis +import java.time.LocalDate + +@OptIn(ExperimentalMaterial3Api::class) +class SelectableDatesImp( + private val limitDate: LocalDate? = null, + private val dateMomentType: DateMomentType, +): SelectableDates { + override fun isSelectableDate(utcTimeMillis: Long): Boolean { + val currentTimeInMillis = System.currentTimeMillis() + val limitInMillis = when(dateMomentType){ + DateMomentType.End -> limitDate?.toEpochMillis(endOfTheDay = true) ?: utcTimeMillis + DateMomentType.Start -> limitDate?.toEpochMillis(endOfTheDay = true) ?: currentTimeInMillis + } + return if (dateMomentType == DateMomentType.Start) utcTimeMillis <= limitInMillis + else utcTimeMillis in limitInMillis..currentTimeInMillis + } + + override fun isSelectableYear(year: Int): Boolean { + val limitYear = limitDate?.year ?: LocalDate.now().year + return year <= limitYear + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsEvent.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsEvent.kt index 44d545a..511c068 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsEvent.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsEvent.kt @@ -1,7 +1,15 @@ package dev.pinkroom.marketsight.ui.news_screen +import dev.pinkroom.marketsight.common.DateMomentType +import dev.pinkroom.marketsight.common.SortType +import dev.pinkroom.marketsight.domain.model.common.SubInfoSymbols + sealed class NewsEvent { data object RetryNews: NewsEvent() data object RefreshNews: NewsEvent() data object LoadMoreNews: NewsEvent() + data class ShowOrHideFilters(val isToShow: Boolean? = null): NewsEvent() + data class ChangeSort(val sort: SortType): NewsEvent() + data class ChangeSymbol(val symbolToChange: SubInfoSymbols): NewsEvent() + data class ChangeDate(val newDateInMillis: Long?, val dateMomentType: DateMomentType): NewsEvent() } diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsScreen.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsScreen.kt index 0476867..59f12a5 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsScreen.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsScreen.kt @@ -3,10 +3,15 @@ package dev.pinkroom.marketsight.ui.news_screen import android.content.Context import android.content.Intent import android.net.Uri +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.SelectableDates +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf @@ -17,19 +22,26 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import dev.pinkroom.marketsight.R import dev.pinkroom.marketsight.common.Constants.BUFFER_LIST +import dev.pinkroom.marketsight.common.DateMomentType +import dev.pinkroom.marketsight.common.SortType import dev.pinkroom.marketsight.domain.model.common.SubInfoSymbols import dev.pinkroom.marketsight.domain.model.news.ImageSize import dev.pinkroom.marketsight.domain.model.news.ImagesNews import dev.pinkroom.marketsight.domain.model.news.NewsInfo import dev.pinkroom.marketsight.ui.core.components.PullToRefreshLazyColumn -import dev.pinkroom.marketsight.ui.core.components.reachedBottom import dev.pinkroom.marketsight.ui.core.theme.dimens +import dev.pinkroom.marketsight.ui.core.util.SelectableDatesImp +import dev.pinkroom.marketsight.ui.core.util.reachedBottom import dev.pinkroom.marketsight.ui.news_screen.components.AllNews +import dev.pinkroom.marketsight.ui.news_screen.components.BottomSheetFilters import dev.pinkroom.marketsight.ui.news_screen.components.EmptyNewsList +import dev.pinkroom.marketsight.ui.news_screen.components.HeaderListNews import dev.pinkroom.marketsight.ui.news_screen.components.MainNews import dev.pinkroom.marketsight.ui.news_screen.components.RealTimeNews +import java.time.LocalDate import java.time.LocalDateTime +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable fun NewsScreen( modifier: Modifier = Modifier, @@ -37,14 +49,23 @@ fun NewsScreen( news: List, realTimeNews: List, symbols: List, + sortBy: SortType, + sortItems: List, + startDate: LocalDate? = null, + endDate: LocalDate? = null, + startSelectableDates: SelectableDates, + endSelectableDates: SelectableDates, isLoading: Boolean, isLoadingMoreNews: Boolean, isRefreshing: Boolean, + isToShowFilters: Boolean, errorMessage: Int? = null, onEvent: (event: NewsEvent) -> Unit, ){ val context = LocalContext.current + val sheetState = rememberModalBottomSheetState() + val listState = rememberLazyListState() val reachedBottom: Boolean by remember { derivedStateOf { listState.reachedBottom(buffer = BUFFER_LIST) } @@ -69,6 +90,16 @@ fun NewsScreen( }, lazyListState = listState, ) { + stickyHeader { + HeaderListNews( + modifier = Modifier + .fillParentMaxWidth(), + isFilterBtnEnabled = !isLoading, + onFilterClick = { + onEvent(NewsEvent.ShowOrHideFilters()) + } + ) + } if (!isLoading && errorMessage != null && news.isEmpty()){ item { EmptyNewsList( @@ -122,6 +153,31 @@ fun NewsScreen( ) } } + BottomSheetFilters( + modifier = Modifier + .fillMaxHeight(dimens.bottomSheetHeight), + isVisible = isToShowFilters, + onDismiss = { + onEvent(NewsEvent.ShowOrHideFilters(isToShow = false)) + }, + sheetState = sheetState, + sortFilters = sortItems, + selectedSort = sortBy, + onSortClick = { + onEvent(NewsEvent.ChangeSort(sort = it)) + }, + symbols = symbols, + onSymbolClick = { + onEvent(NewsEvent.ChangeSymbol(symbolToChange = it)) + }, + startDate = startDate, + endDate = endDate, + changeDate = { dateInMillis, dateMomentType -> + onEvent(NewsEvent.ChangeDate(newDateInMillis = dateInMillis, dateMomentType = dateMomentType)) + }, + startSelectableDates = startSelectableDates, + endSelectableDates = endSelectableDates, + ) } fun Context.navigateToNews(newsInfo: NewsInfo){ @@ -129,6 +185,7 @@ fun Context.navigateToNews(newsInfo: NewsInfo){ startActivity(intent) } +@OptIn(ExperimentalMaterial3Api::class) @Preview( showBackground = true, showSystemUi = true, @@ -143,16 +200,16 @@ fun NewsScreenPreview(){ images = listOf( ImagesNews( size = ImageSize.Small, - url = "https://cdn.benzinga.com/files/imagecache/1024x768xUP/market-clubhouse-morning-memo_201.png", + url = "https://cdn.benzinga.com/files/imagecache/1024x768xUP/market-clubhouse-morning-memo_203.png", ) ), source = "Bezinga", - url = "https://www.benzinga.com/", + url = "https://www.benzinga.pt/", updatedAt = LocalDateTime.now(), createdAt = LocalDateTime.now(), author = "RIPS", - summary = "Good Morning Traders! In today's Market Clubhouse Morning Memo, we will discuss SPY, QQQ, AAPL, MSFT, NVDA, GOOGL, META, and TSLA.", - headline = "Market Clubhouse Morning Memo - April 11th, 2024 (Trade Strategy For SPY, QQQ, AAPL, MSFT, NVDA, GOOGL, META, And TSLA)", + summary = "Good Morning Traders! In today's Market house Morning Memo, we will discuss SPY, QQQ, AAPL, MSFT, NVDA, GOOGL, META, and TSLA.", + headline = "Market Clubhouse Morning Memo - April 11th, 2024 (Trade Strategy For SPY, QQQ,PL, MSFT, NVDA, GOOGL, META, And TSLA)", ), NewsInfo( id = 2L, @@ -160,7 +217,7 @@ fun NewsScreenPreview(){ images = listOf( ImagesNews( size = ImageSize.Small, - url = "https://cdn.benzinga.com/files/imagecache/1024x768xUP/market-clubhouse-morning-memo_201.png", + url = "https://cdn.benzinga.com/files/imagecache/1024x768xUP/market-clubhouse-morning-memo_202.png", ) ), source = "Bezinga", @@ -168,8 +225,8 @@ fun NewsScreenPreview(){ updatedAt = LocalDateTime.now(), createdAt = LocalDateTime.now(), author = "RIPS", - summary = "Good Morning Traders! In today's Market Clubhouse Morning Memo, we will discuss SPY, QQQ, AAPL, MSFT, NVDA, GOOGL, META, and TSLA.", - headline = "Market Clubhouse Morning Memo - April 11th, 2024 (Trade Strategy For SPY, QQQ, AAPL, MSFT, NVDA, GOOGL, META, And TSLA)", + summary = "Good Morning Traders! In today's Market Clubhouse Morning Memo, we wiscuss SPY, QQQ, AAPL, MSFT, NVDA, GOOGL, META, and TSLA.", + headline = "Market Clubhouse Morning Memo - April 11th, 2024 (Trade Strategy For QQQ, AAPL, MSFT, NVDA, GOOGL, META, And TSLA)", ), ), mainNews = listOf( @@ -179,16 +236,16 @@ fun NewsScreenPreview(){ images = listOf( ImagesNews( size = ImageSize.Small, - url = "https://cdn.benzinga.com/files/imagecache/1024x768xUP/market-clubhouse-morning-memo_201.png", + url = "https://cdn.benzinga.com/files/imagecache/1024x768xUP/market-clubhouse-morning-memo_208.png", ) ), source = "Bezinga", - url = "https://www.benzinga.com/", + url = "https://www.benzinga.c/", updatedAt = LocalDateTime.now(), createdAt = LocalDateTime.now(), author = "RIPS", - summary = "Good Morning Traders! In today's Market Clubhouse Morning Memo, we will discuss SPY, QQQ, AAPL, MSFT, NVDA, GOOGL, META, and TSLA.", - headline = "Market Clubhouse Morning Memo - April 11th, 2024 (Trade Strategy For SPY, QQQ, AAPL, MSFT, NVDA, GOOGL, META, And TSLA)", + summary = "Good Morning Traders! In today's Market Clubhouse Memo, we will discuss SPY, QQQ, AAPL, MSFT, NVDA, GOOGL, META, and TSLA.", + headline = "Market Clubhouse Morning Memo - April 11th, 2024 (Trade Stregy For SPY, QQQ, AAPL, MSFT, NVDA, GOOGL, META, And TSLA)", ), NewsInfo( id = 2L, @@ -196,16 +253,16 @@ fun NewsScreenPreview(){ images = listOf( ImagesNews( size = ImageSize.Small, - url = "https://cdn.benzinga.com/files/imagecache/1024x768xUP/market-clubhouse-morning-memo_201.png", + url = "https://cdn.benzinga.com/files/imagecache/1024x768xUP/market-clubhouse-morning-memo_200.png", ) ), source = "Bezinga", - url = "https://www.benzinga.com/", + url = "https://www.benzinga.fr/", updatedAt = LocalDateTime.now(), createdAt = LocalDateTime.now(), author = "RIPS", - summary = "Good Morning Traders! In today's Market Clubhouse Morning Memo, we will discuss SPY, QQQ, AAPL, MSFT, NVDA, GOOGL, META, and TSLA.", - headline = "Market Clubhouse Morning Memo - April 11th, 2024 (Trade Strategy For SPY, QQQ, AAPL, MSFT, NVDA, GOOGL, META, And TSLA)", + summary = "Good Morning Traders! In today's Market Morning Memo, we will discuss SPY, QQQ, AAPL, MSFT, NVDA, GOOGL, META, and TSLA.", + headline = "Market Clubhouse Morning Memo - April 2024 (Trade Strategy For SPY, QQQ, AAPL, MSFT, NVDA, GOOGL, META, And TSLA)", ), ), realTimeNews = listOf( @@ -214,19 +271,19 @@ fun NewsScreenPreview(){ symbols = listOf("TSLA","AAPL","BTC"), images = null, source = "Bezinga", - url = "https://www.benzinga.com/", + url = "https://www.benzinga.de/", updatedAt = LocalDateTime.now(), createdAt = LocalDateTime.now(), author = "RIPS", - summary = "Good Morning Traders! In today's Market Clubhouse Morning Memo, we will discuss SPY, QQQ, AAPL, MSFT, NVDA, GOOGL, META, and TSLA.", - headline = "Market Clubhouse Morning Memo - April 11th, 2024 (Trade Strategy For SPY, QQQ, AAPL, MSFT, NVDA, GOOGL, META, And TSLA)", + summary = "Good Morning Traders! In today's Market Couse Morning Memo, we will discuss SPY, QQQ, AAPL, MSFT, NVDA, GOOGL, META, and TSLA.", + headline = "Market Clubhouse Morning Memo - April 11th, 24 (Trade Strategy For SPY, QQQ, AAPL, MSFT, NVDA, GOOGL, META, And TSLA)", ), NewsInfo( id = 2L, symbols = listOf("TSLA","AAPL","BTC"), images = null, source = "Bezinga", - url = "https://www.benzinga.com/", + url = "https://www.benzinga.coo/", updatedAt = LocalDateTime.now(), createdAt = LocalDateTime.now(), author = "RIPS", @@ -246,6 +303,11 @@ fun NewsScreenPreview(){ isLoadingMoreNews = false, errorMessage = R.string.get_news_error_message, isRefreshing = false, + isToShowFilters = false, + sortItems = listOf(), + sortBy = SortType.DESC, onEvent = {}, + startSelectableDates = SelectableDatesImp(dateMomentType = DateMomentType.Start), + endSelectableDates = SelectableDatesImp(dateMomentType = DateMomentType.End), ) } \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsUiState.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsUiState.kt index 36f2c2c..5f46aa5 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsUiState.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsUiState.kt @@ -1,16 +1,30 @@ package dev.pinkroom.marketsight.ui.news_screen import androidx.annotation.DrawableRes +import dev.pinkroom.marketsight.common.DateMomentType +import dev.pinkroom.marketsight.common.SortType +import dev.pinkroom.marketsight.common.popularSymbols import dev.pinkroom.marketsight.domain.model.common.SubInfoSymbols import dev.pinkroom.marketsight.domain.model.news.NewsInfo +import dev.pinkroom.marketsight.ui.core.util.SelectableDatesImp +import java.time.LocalDate data class NewsUiState( val isLoading: Boolean = true, val isLoadingMoreItems: Boolean = false, val isRefreshing: Boolean = false, + val isToShowFilters: Boolean = false, @DrawableRes val errorMessage: Int? = null, val mainNews: List = listOf(), val news: List = listOf(), val realTimeNews: List = listOf(), - val symbols: List = listOf(), + val symbols: List = popularSymbols, + val sort: List = listOf(SortType.DESC, SortType.ASC), + val sortBy: SortType = SortType.DESC, + val startDateSort: LocalDate? = null, + val endDateSort: LocalDate? = null, + val startSelectableDates: SelectableDatesImp = SelectableDatesImp(dateMomentType = DateMomentType.Start), + val endSelectableDates: SelectableDatesImp = SelectableDatesImp(dateMomentType = DateMomentType.End) ) + + diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsViewModel.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsViewModel.kt index 532d3ee..682d4d6 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsViewModel.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsViewModel.kt @@ -4,18 +4,23 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import dev.pinkroom.marketsight.R +import dev.pinkroom.marketsight.common.Constants.ALL_SYMBOLS import dev.pinkroom.marketsight.common.Constants.MAX_ITEMS_CAROUSEL +import dev.pinkroom.marketsight.common.DateMomentType import dev.pinkroom.marketsight.common.DispatcherProvider import dev.pinkroom.marketsight.common.Resource +import dev.pinkroom.marketsight.common.SortType import dev.pinkroom.marketsight.common.connection_network.ConnectivityObserver import dev.pinkroom.marketsight.common.connection_network.ConnectivityObserver.Status.Available import dev.pinkroom.marketsight.common.connection_network.ConnectivityObserver.Status.Unavailable import dev.pinkroom.marketsight.common.paginator.DefaultPagination import dev.pinkroom.marketsight.domain.model.common.PaginationInfo +import dev.pinkroom.marketsight.domain.model.common.SubInfoSymbols import dev.pinkroom.marketsight.domain.model.news.NewsResponse import dev.pinkroom.marketsight.domain.use_case.news.ChangeFilterNews import dev.pinkroom.marketsight.domain.use_case.news.GetNews import dev.pinkroom.marketsight.domain.use_case.news.GetRealTimeNews +import dev.pinkroom.marketsight.ui.core.util.SelectableDatesImp import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow @@ -24,6 +29,8 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import java.time.Instant +import java.time.ZoneOffset import javax.inject.Inject @HiltViewModel @@ -74,15 +81,19 @@ class NewsViewModel @Inject constructor( fetchRealTimeNews() } - fun onEvent(event: NewsEvent){ - when(event){ + fun onEvent(event: NewsEvent) { + when(event) { NewsEvent.RetryNews -> retryToGetNews() NewsEvent.RefreshNews -> refreshNews() NewsEvent.LoadMoreNews -> loadMoreNews() + is NewsEvent.ShowOrHideFilters -> showOrHideFilters(isToShow = event.isToShow) + is NewsEvent.ChangeSort -> changeSort(newSort = event.sort) + is NewsEvent.ChangeSymbol -> changeSymbols(symbolToChange = event.symbolToChange) + is NewsEvent.ChangeDate -> changeDate(dateInMillis = event.newDateInMillis, dateMomentType = event.dateMomentType) } } - private fun observeNetworkStatus(){ + private fun observeNetworkStatus() { viewModelScope.launch(dispatchers.IO) { connectivityObserver.observe().distinctUntilChanged().collect{ statusNet -> connectionStatus = statusNet @@ -92,7 +103,7 @@ class NewsViewModel @Inject constructor( } } - private fun initNews(){ + private fun initNews() { initNewsJob = viewModelScope.launch(dispatchers.IO) { _uiState.update { it.copy(isLoading = true) } when(val response = getNews()){ @@ -139,7 +150,7 @@ class NewsViewModel @Inject constructor( } } - private fun loadMoreNews(){ + private fun loadMoreNews() { viewModelScope.launch(dispatchers.IO) { if (!paginationInfo.isLoading) pagination.loadNextItems() } @@ -152,4 +163,55 @@ class NewsViewModel @Inject constructor( } } } + + private fun showOrHideFilters(isToShow: Boolean? = null) { + if (uiState.value.isLoading) return + _uiState.update { it.copy(isToShowFilters = isToShow ?: !it.isToShowFilters) } + } + + private fun changeSort(newSort: SortType) { + if (uiState.value.sortBy == newSort) return + _uiState.update { it.copy(sortBy = newSort) } + } + + private fun changeSymbols(symbolToChange: SubInfoSymbols) { + val symbolsSubscribed = uiState.value.symbols.filter { it.isSubscribed } + val totalSizeSubscribed = if (symbolsSubscribed.contains(symbolToChange)) + symbolsSubscribed.size - 1 + else symbolsSubscribed.size + + val needToSubscribeAll = totalSizeSubscribed == 0 || symbolToChange.name == ALL_SYMBOLS + + val newListSymbols = uiState.value.symbols.map { item -> + when { + item.name == ALL_SYMBOLS -> item.copy(isSubscribed = needToSubscribeAll) + item.symbol == symbolToChange.symbol -> item.copy(isSubscribed = !item.isSubscribed) + needToSubscribeAll -> item.copy(isSubscribed = false) + else -> item.copy() + } + } + _uiState.update { it.copy(symbols = newListSymbols) } + } + + private fun changeDate(dateInMillis: Long?, dateMomentType: DateMomentType){ + val date = dateInMillis?.let { Instant.ofEpochMilli(it).atZone(ZoneOffset.UTC).toLocalDate() } + when(dateMomentType){ + DateMomentType.End -> { + _uiState.update { + it.copy( + endDateSort = date, + startSelectableDates = SelectableDatesImp(limitDate = date, dateMomentType = DateMomentType.Start), + ) + } + } + DateMomentType.Start -> { + _uiState.update { + it.copy( + startDateSort = date, + endSelectableDates = SelectableDatesImp(limitDate = date, dateMomentType = DateMomentType.End), + ) + } + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/BottomSheetFilters.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/BottomSheetFilters.kt new file mode 100644 index 0000000..3fc8193 --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/BottomSheetFilters.kt @@ -0,0 +1,241 @@ +package dev.pinkroom.marketsight.ui.news_screen.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SelectableDates +import androidx.compose.material3.SheetState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import dev.pinkroom.marketsight.R +import dev.pinkroom.marketsight.common.DateMomentType +import dev.pinkroom.marketsight.common.SortType +import dev.pinkroom.marketsight.common.toReadableDate +import dev.pinkroom.marketsight.domain.model.common.SubInfoSymbols +import dev.pinkroom.marketsight.ui.core.components.ButtonFilter +import dev.pinkroom.marketsight.ui.core.components.DatePickerComponent +import dev.pinkroom.marketsight.ui.core.components.DefaultSectionFilter +import dev.pinkroom.marketsight.ui.core.theme.dimens +import java.time.LocalDate + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BottomSheetFilters( + modifier: Modifier = Modifier, + sortFilters: List, + selectedSort: SortType, + symbols: List, + startDate: LocalDate? = null, + endDate: LocalDate? = null, + isVisible: Boolean, + onDismiss: () -> Unit, + onSortClick: (sort: SortType) -> Unit, + onSymbolClick: (symbol: SubInfoSymbols) -> Unit, + changeDate: (dateInMillis: Long?, dateMomentType: DateMomentType) -> Unit, + startSelectableDates: SelectableDates, + endSelectableDates: SelectableDates, + sheetState: SheetState, +){ + AnimatedVisibility(visible = isVisible) { + ModalBottomSheet( + modifier = modifier, + onDismissRequest = onDismiss, + sheetState = sheetState, + ) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(dimens.smallPadding), + contentPadding = PaddingValues( + bottom = dimens.normalPadding, + ) + ) { + item { + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = dimens.horizontalPadding), + text = stringResource(id = R.string.filters_news), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + } + item { + DateRangePickerSection( + startDate = startDate, + endDate = endDate, + changeDate = changeDate, + startSelectableDates = startSelectableDates, + endSelectableDates = endSelectableDates, + ) + } + item { + SortSection( + modifier = Modifier + .fillMaxWidth(), + itemsSort = sortFilters, + selectedSort = selectedSort, + onSortClick = onSortClick, + ) + } + item { + SymbolsSubscribedSection( + modifier = Modifier + .fillMaxWidth(), + symbols = symbols, + onSymbolClick = onSymbolClick, + ) + } + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun SortSection( + modifier: Modifier, + itemsSort: List, + selectedSort: SortType, + onSortClick: (sort: SortType) -> Unit, +){ + DefaultSectionFilter( + title = stringResource(id = R.string.filter_sort), + ){ + FlowRow( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = dimens.horizontalPadding, vertical = dimens.xSmallPadding), + horizontalArrangement = Arrangement.spacedBy(dimens.smallPadding), + ) { + itemsSort.forEach { sort -> + ButtonFilter( + isSelected = sort == selectedSort, + text = stringResource(id = sort.stringId), + onClick = { + onSortClick(sort) + } + ) + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun SymbolsSubscribedSection( + modifier: Modifier, + symbols: List, + onSymbolClick: (symbol: SubInfoSymbols) -> Unit, +){ + DefaultSectionFilter( + title = stringResource(id = R.string.filter_symbols), + showBottomDivider = true, + ){ + FlowRow( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = dimens.horizontalPadding, vertical = dimens.xSmallPadding), + horizontalArrangement = Arrangement.spacedBy(dimens.smallPadding), + ) { + symbols.forEach { symbol -> + ButtonFilter( + isSelected = symbol.isSubscribed, + text = symbol.name, + onClick = { + onSymbolClick(symbol) + } + ) + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class) +@Composable +fun DateRangePickerSection( + modifier: Modifier = Modifier, + startDate: LocalDate? = null, + endDate: LocalDate? = null, + startSelectableDates: SelectableDates, + endSelectableDates: SelectableDates, + changeDate: (dateInMillis: Long?, dateMomentType: DateMomentType) -> Unit, +){ + var isToShowStartDatePicker by rememberSaveable { + mutableStateOf(false) + } + var isToShowEndDatePicker by rememberSaveable { + mutableStateOf(false) + } + + val startDateText = if (startDate != null) stringResource(id = R.string.start_date) + ": ${startDate.toReadableDate()}" + else stringResource(id = R.string.start_date) + + val endDateText = if (endDate != null) stringResource(id = R.string.end_date) + ": ${endDate.toReadableDate()}" + else stringResource(id = R.string.end_date) + + DefaultSectionFilter( + title = stringResource(id = R.string.date_range), + ){ + FlowRow( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = dimens.horizontalPadding, vertical = dimens.xSmallPadding), + horizontalArrangement = Arrangement.spacedBy(dimens.smallPadding), + ) { + ButtonFilter( + isSelected = false, + text = startDateText, + onClick = { + isToShowStartDatePicker = true + } + ) + ButtonFilter( + isSelected = false, + text = endDateText, + onClick = { + isToShowEndDatePicker = true + } + ) + } + } + + DatePickerComponent( + initialDate = startDate, + isToShowDatePicker = isToShowStartDatePicker, + saveNewDate = { + changeDate(it,DateMomentType.Start) + }, + dismissDatePicker = { + isToShowStartDatePicker = false + }, + selectableDates = startSelectableDates, + ) + DatePickerComponent( + initialDate = endDate, + isToShowDatePicker = isToShowEndDatePicker, + saveNewDate = { + changeDate(it,DateMomentType.End) + }, + dismissDatePicker = { + isToShowEndDatePicker = false + }, + selectableDates = endSelectableDates, + ) +} + diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/HeaderListNews.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/HeaderListNews.kt new file mode 100644 index 0000000..abad84a --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/HeaderListNews.kt @@ -0,0 +1,53 @@ +package dev.pinkroom.marketsight.ui.news_screen.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import dev.pinkroom.marketsight.R +import dev.pinkroom.marketsight.ui.core.theme.dimens + +@Composable +fun HeaderListNews( + modifier: Modifier = Modifier, + isFilterBtnEnabled: Boolean, + onFilterClick: () -> Unit, +){ + Row( + modifier = modifier + .background( + color = MaterialTheme.colorScheme.background, + ), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + modifier = Modifier + .padding(horizontal = dimens.horizontalPadding) + .padding(bottom = dimens.contentTopPadding), + text = stringResource(id = R.string.news), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + Icon( + modifier = Modifier + .padding(horizontal = dimens.horizontalPadding) + .size(dimens.normalIconSize) + .clickable( + enabled = isFilterBtnEnabled, + onClick = onFilterClick, + ), + painter = painterResource(id = R.drawable.icon_filter_list), + contentDescription = stringResource(id = R.string.filter_list_news_btn), + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/RealTimeNews.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/RealTimeNews.kt index da6bebc..f583189 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/RealTimeNews.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/RealTimeNews.kt @@ -1,25 +1,41 @@ package dev.pinkroom.marketsight.ui.news_screen.components import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring import androidx.compose.animation.fadeIn import androidx.compose.animation.slideInHorizontally +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text 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.draw.scale import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import dev.pinkroom.marketsight.R import dev.pinkroom.marketsight.domain.model.news.NewsInfo +import dev.pinkroom.marketsight.ui.core.theme.Red import dev.pinkroom.marketsight.ui.core.theme.dimens @Composable @@ -29,21 +45,50 @@ fun RealTimeNews( isLoading: Boolean, onNewsClick: (news: NewsInfo) -> Unit, ){ + var liveUpdate by rememberSaveable { + mutableStateOf(false) + } + val sizeLiveIcon by animateFloatAsState( + targetValue = if (liveUpdate) 1.3f else 1f, + animationSpec = spring( + stiffness = Spring.StiffnessMedium, + dampingRatio = Spring.DampingRatioHighBouncy, + ), + finishedListener = { + liveUpdate = false + }, + label = "Animation Live Circle" + ) + LaunchedEffect(news.size) { + liveUpdate = true + } + AnimatedVisibility( visible = news.isNotEmpty() && !isLoading, enter = slideInHorizontally() + fadeIn() ) { Column( modifier = modifier, - verticalArrangement = Arrangement.spacedBy(dimens.smallPadding) + verticalArrangement = Arrangement.spacedBy(dimens.smallPadding), ) { - Text( - modifier = Modifier - .padding(horizontal = dimens.horizontalPadding), - text = stringResource(id = R.string.real_time), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(dimens.smallPadding), + ) { + Text( + modifier = Modifier + .padding(start = dimens.horizontalPadding), + text = stringResource(id = R.string.real_time), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + Box( + modifier = Modifier + .size(dimens.smallIconSize) + .scale(sizeLiveIcon) + .background(color = Red, shape = CircleShape) + ) + } LazyRow( modifier = Modifier .fillMaxWidth(), diff --git a/app/src/main/res/drawable/icon_check.xml b/app/src/main/res/drawable/icon_check.xml new file mode 100644 index 0000000..ce50926 --- /dev/null +++ b/app/src/main/res/drawable/icon_check.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/icon_filter_list.xml b/app/src/main/res/drawable/icon_filter_list.xml new file mode 100644 index 0000000..ce0e502 --- /dev/null +++ b/app/src/main/res/drawable/icon_filter_list.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 49f03f9..f089291 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -8,4 +8,17 @@ Live News Latest News Unable to find news for selected filters. + Filter list news button + Filters News + Sort + Ascending + Descending + Symbols + All + Select + Cancel + Date Range + Start Date + End Date + Clear Date \ No newline at end of file From 3f639fa6be334ab3126819835a50615bfff2ac11 Mon Sep 17 00:00:00 2001 From: LucasPrioste Date: Fri, 26 Apr 2024 16:43:40 +0200 Subject: [PATCH 21/24] - logic to apply filters on VM --- .../dev/pinkroom/marketsight/common/Utils.kt | 12 ++- .../common/paginator/DefaultPagination.kt | 2 +- .../data/data_source/NewsRemoteDataSource.kt | 8 +- .../marketsight/data/remote/AlpacaNewsApi.kt | 2 + .../data/repository/NewsRepositoryImp.kt | 15 ++- .../domain/model/news/NewsFilters.kt | 20 ++++ .../domain/model/news/NewsResponse.kt | 2 +- .../domain/repository/NewsRepository.kt | 3 + .../domain/use_case/news/GetNews.kt | 10 ++ .../pinkroom/marketsight/ui/MainActivity.kt | 22 ++--- .../ui/core/navigation/NavigationAppHost.kt | 28 +++--- .../ui/core/util/SelectableDatesImp.kt | 2 +- .../marketsight/ui/news_screen/NewsAction.kt | 1 + .../marketsight/ui/news_screen/NewsEvent.kt | 4 + .../marketsight/ui/news_screen/NewsScreen.kt | 62 ++++++------ .../marketsight/ui/news_screen/NewsUiState.kt | 15 +-- .../ui/news_screen/NewsViewModel.kt | 97 +++++++++++++++---- .../components/BottomSheetFilters.kt | 67 ++++++++++--- app/src/main/res/values/strings.xml | 3 + 19 files changed, 267 insertions(+), 108 deletions(-) create mode 100644 app/src/main/java/dev/pinkroom/marketsight/domain/model/news/NewsFilters.kt diff --git a/app/src/main/java/dev/pinkroom/marketsight/common/Utils.kt b/app/src/main/java/dev/pinkroom/marketsight/common/Utils.kt index 797697d..9ecd060 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/common/Utils.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/common/Utils.kt @@ -8,6 +8,7 @@ import dev.pinkroom.marketsight.domain.model.common.SubInfoSymbols import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import java.time.LocalDate +import java.time.LocalDateTime import java.time.ZoneOffset import java.time.format.DateTimeFormatter import java.time.format.FormatStyle @@ -39,6 +40,7 @@ fun LocalDate.toEpochMillis(zoneOffset: ZoneOffset = ZoneOffset.UTC, endOfTheDay val time = if (endOfTheDay) atTime(23,59) else atStartOfDay() return time.atOffset(zoneOffset).toInstant().toEpochMilli() } + fun LocalDate.toReadableDate(): String { val formatter = DateTimeFormatter .ofLocalizedDate(FormatStyle.SHORT) @@ -46,6 +48,10 @@ fun LocalDate.toReadableDate(): String { return format(formatter) } +fun LocalDateTime.formatToStandardIso(): String = format(DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'")) + +fun LocalDate.atEndOfTheDay() = atTime(23,59,59).atOffset(ZoneOffset.UTC).toLocalDateTime() + sealed class ActionAlpaca(val action: String) { data object Subscribe: ActionAlpaca(action = "subscribe") data object Unsubscribe: ActionAlpaca(action = "unsubscribe") @@ -115,17 +121,17 @@ val popularSymbols = listOf( ), SubInfoSymbols( name = "Bitcoin", - symbol = "BTC/USD", + symbol = "BTC", isSubscribed = false, ), SubInfoSymbols( name = "Ethereum", - symbol = "ETH/USD", + symbol = "ETH", isSubscribed = false, ), SubInfoSymbols( name = "Shiba", - symbol = "SHIB/USD", + symbol = "SHIB", isSubscribed = false, ), ) \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/common/paginator/DefaultPagination.kt b/app/src/main/java/dev/pinkroom/marketsight/common/paginator/DefaultPagination.kt index 01d78e0..3e70c06 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/common/paginator/DefaultPagination.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/common/paginator/DefaultPagination.kt @@ -6,7 +6,7 @@ class DefaultPagination( private val initialKey: Key?, private inline val onLoadUpdated: (Boolean) -> Unit, private inline val onRequest: suspend (nextKey: Key?) -> Resource, - private inline val getNextKey: suspend (T) -> Key, + private inline val getNextKey: suspend (T) -> Key?, private inline val onError: suspend (message: String?) -> Unit, private inline val onSuccess: suspend (data: T, newKey: Key?) -> Unit ): Pagination { diff --git a/app/src/main/java/dev/pinkroom/marketsight/data/data_source/NewsRemoteDataSource.kt b/app/src/main/java/dev/pinkroom/marketsight/data/data_source/NewsRemoteDataSource.kt index c323c5d..d999cd3 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/data/data_source/NewsRemoteDataSource.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/data/data_source/NewsRemoteDataSource.kt @@ -8,6 +8,7 @@ import dev.pinkroom.marketsight.common.DispatcherProvider import dev.pinkroom.marketsight.common.HelperIdentifierMessagesAlpacaService import dev.pinkroom.marketsight.common.Resource import dev.pinkroom.marketsight.common.SortType +import dev.pinkroom.marketsight.common.formatToStandardIso import dev.pinkroom.marketsight.common.toObject import dev.pinkroom.marketsight.data.remote.AlpacaNewsApi import dev.pinkroom.marketsight.data.remote.AlpacaNewsService @@ -19,6 +20,7 @@ import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.take +import java.time.LocalDateTime import javax.inject.Inject class NewsRemoteDataSource @Inject constructor( @@ -73,13 +75,17 @@ class NewsRemoteDataSource @Inject constructor( symbols: List? = null, limit: Int? = Constants.LIMIT_NEWS, pageToken: String?, - sort: SortType? = null + sort: SortType? = null, + startDate: LocalDateTime? = null, + endDate: LocalDateTime? = null, ): NewsResponseDto { return alpacaNewsApi.getNews( symbols = symbols?.joinToString(","), perPage = limit, pageToken = pageToken, sort = sort?.type, + startDate = startDate?.formatToStandardIso(), + endDate = endDate?.formatToStandardIso(), ) } } \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/data/remote/AlpacaNewsApi.kt b/app/src/main/java/dev/pinkroom/marketsight/data/remote/AlpacaNewsApi.kt index 2bbf17f..b33846d 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/data/remote/AlpacaNewsApi.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/data/remote/AlpacaNewsApi.kt @@ -11,5 +11,7 @@ interface AlpacaNewsApi { @Query("limit") perPage: Int? = null, @Query("page_token") pageToken: String? = null, @Query("sort") sort: String? = null, + @Query("start") startDate: String? = null, + @Query("end") endDate: String? = null, ): NewsResponseDto } \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/data/repository/NewsRepositoryImp.kt b/app/src/main/java/dev/pinkroom/marketsight/data/repository/NewsRepositoryImp.kt index 50a5efb..f91001c 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/data/repository/NewsRepositoryImp.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/data/repository/NewsRepositoryImp.kt @@ -12,7 +12,8 @@ import dev.pinkroom.marketsight.domain.model.news.NewsResponse import dev.pinkroom.marketsight.domain.repository.NewsRepository import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.single +import kotlinx.coroutines.flow.last +import java.time.LocalDateTime import javax.inject.Inject class NewsRepositoryImp @Inject constructor( @@ -33,17 +34,21 @@ class NewsRepositoryImp @Inject constructor( val message = MessageAlpacaService(action = actionAlpaca.action, news = symbols) newsRemoteDataSource.sendSubscribeMessageToAlpacaService(message = message).collect{ response -> when(response){ - is Resource.Error -> emit(Resource.Error(data = symbols)) + is Resource.Error -> { + emit(Resource.Error(data = symbols)) + } is Resource.Success -> emit(Resource.Success(data = response.data.news ?: symbols)) } } - }.flowOn(dispatchers.IO).single() + }.flowOn(dispatchers.IO).last() override suspend fun getNews( symbols: List?, limit: Int?, pageToken: String?, - sort: SortType? + sort: SortType?, + startDate: LocalDateTime?, + endDate: LocalDateTime?, ): Resource { return try { val response = newsRemoteDataSource.getNews( @@ -51,6 +56,8 @@ class NewsRepositoryImp @Inject constructor( limit = limit, pageToken = pageToken, sort = sort, + startDate = startDate, + endDate = endDate, ) Resource.Success(data = response.toNewsResponse()) } catch (e: Exception){ diff --git a/app/src/main/java/dev/pinkroom/marketsight/domain/model/news/NewsFilters.kt b/app/src/main/java/dev/pinkroom/marketsight/domain/model/news/NewsFilters.kt new file mode 100644 index 0000000..01f8453 --- /dev/null +++ b/app/src/main/java/dev/pinkroom/marketsight/domain/model/news/NewsFilters.kt @@ -0,0 +1,20 @@ +package dev.pinkroom.marketsight.domain.model.news + +import dev.pinkroom.marketsight.common.Constants.ALL_SYMBOLS +import dev.pinkroom.marketsight.common.SortType +import dev.pinkroom.marketsight.common.popularSymbols +import dev.pinkroom.marketsight.domain.model.common.SubInfoSymbols +import java.time.LocalDate + +data class NewsFilters( + val symbols: List = popularSymbols, + val sort: List = listOf(SortType.DESC, SortType.ASC), + val sortBy: SortType = SortType.DESC, + val startDateSort: LocalDate? = null, + val endDateSort: LocalDate? = null, +){ + fun getSubscribedSymbols() = symbols + .filter { it.isSubscribed && it.name != ALL_SYMBOLS } + .map { it.symbol } + .ifEmpty { null } +} diff --git a/app/src/main/java/dev/pinkroom/marketsight/domain/model/news/NewsResponse.kt b/app/src/main/java/dev/pinkroom/marketsight/domain/model/news/NewsResponse.kt index e8f6933..74aa163 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/domain/model/news/NewsResponse.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/domain/model/news/NewsResponse.kt @@ -2,5 +2,5 @@ package dev.pinkroom.marketsight.domain.model.news data class NewsResponse( val news: List, - val nextPageToken: String, + val nextPageToken: String? = null, ) diff --git a/app/src/main/java/dev/pinkroom/marketsight/domain/repository/NewsRepository.kt b/app/src/main/java/dev/pinkroom/marketsight/domain/repository/NewsRepository.kt index dfb6354..c0d39de 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/domain/repository/NewsRepository.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/domain/repository/NewsRepository.kt @@ -7,6 +7,7 @@ import dev.pinkroom.marketsight.common.SortType import dev.pinkroom.marketsight.domain.model.news.NewsInfo import dev.pinkroom.marketsight.domain.model.news.NewsResponse import kotlinx.coroutines.flow.Flow +import java.time.LocalDateTime interface NewsRepository { fun getRealTimeNews(): Flow> @@ -20,5 +21,7 @@ interface NewsRepository { limit: Int? = LIMIT_NEWS, pageToken: String? = null, sort: SortType? = SortType.DESC, + startDate: LocalDateTime? = null, + endDate: LocalDateTime? = null, ): Resource } \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/domain/use_case/news/GetNews.kt b/app/src/main/java/dev/pinkroom/marketsight/domain/use_case/news/GetNews.kt index 431d008..75c7506 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/domain/use_case/news/GetNews.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/domain/use_case/news/GetNews.kt @@ -1,7 +1,9 @@ package dev.pinkroom.marketsight.domain.use_case.news import dev.pinkroom.marketsight.common.Constants.LIMIT_NEWS +import dev.pinkroom.marketsight.common.SortType import dev.pinkroom.marketsight.domain.repository.NewsRepository +import java.time.LocalDateTime import javax.inject.Inject class GetNews @Inject constructor( @@ -10,8 +12,16 @@ class GetNews @Inject constructor( suspend operator fun invoke( pageToken: String? = null, limitPerPage: Int? = LIMIT_NEWS, + sortType: SortType? = SortType.DESC, + symbols: List? = null, + startDate: LocalDateTime? = null, + endDate: LocalDateTime? = null, ) = newsRepository.getNews( pageToken = pageToken, limit = limitPerPage, + sort = sortType, + symbols = symbols, + startDate = startDate, + endDate = endDate, ) } \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/MainActivity.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/MainActivity.kt index 61c58e5..e1b8b4f 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/MainActivity.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/MainActivity.kt @@ -8,9 +8,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult import androidx.compose.material3.Surface import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.navigation.compose.rememberNavController @@ -19,7 +19,6 @@ import dev.pinkroom.marketsight.ui.core.navigation.NavigationAppHost import dev.pinkroom.marketsight.ui.core.navigation.NavigationBottomBar import dev.pinkroom.marketsight.ui.core.navigation.Route import dev.pinkroom.marketsight.ui.core.theme.MarketSightTheme -import kotlinx.coroutines.launch @AndroidEntryPoint class MainActivity : ComponentActivity() { @@ -31,7 +30,7 @@ class MainActivity : ComponentActivity() { val navController = rememberNavController() val startDestination = Route.NewsScreen val snackBarHostState = remember { SnackbarHostState() } - val scope = rememberCoroutineScope() + MarketSightTheme { Surface( modifier = Modifier.fillMaxSize(), @@ -52,15 +51,14 @@ class MainActivity : ComponentActivity() { modifier = Modifier.padding(padding), navController = navController, startDestination = startDestination, - onShowSnackBar = { message, duration -> - scope.launch { - snackBarHostState.currentSnackbarData?.dismiss() - snackBarHostState.showSnackbar( - message = message, - duration = duration, - withDismissAction = true, - ) - } + onShowSnackBar = { message, duration, actionMessage -> + snackBarHostState.currentSnackbarData?.dismiss() + snackBarHostState.showSnackbar( + message = message, + duration = duration, + withDismissAction = true, + actionLabel = actionMessage, + ) == SnackbarResult.ActionPerformed } ) } diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/NavigationAppHost.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/NavigationAppHost.kt index 0663688..72b9330 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/NavigationAppHost.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/NavigationAppHost.kt @@ -1,8 +1,8 @@ package dev.pinkroom.marketsight.ui.core.navigation -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.SnackbarDuration import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.hilt.navigation.compose.hiltViewModel @@ -18,18 +18,21 @@ import dev.pinkroom.marketsight.ui.detail_screen.DetailScreen import dev.pinkroom.marketsight.ui.home_screen.HomeScreen import dev.pinkroom.marketsight.ui.home_screen.HomeViewModel import dev.pinkroom.marketsight.ui.news_screen.NewsAction +import dev.pinkroom.marketsight.ui.news_screen.NewsEvent import dev.pinkroom.marketsight.ui.news_screen.NewsScreen import dev.pinkroom.marketsight.ui.news_screen.NewsViewModel +import kotlinx.coroutines.launch -@OptIn(ExperimentalMaterial3Api::class) @Composable fun NavigationAppHost( modifier: Modifier = Modifier, navController: NavHostController, startDestination: Route, - onShowSnackBar: (message: String, duration: SnackbarDuration) -> Unit, + onShowSnackBar: suspend (message: String, duration: SnackbarDuration, action: String?) -> Boolean, ){ val context = LocalContext.current + val scope = rememberCoroutineScope() + NavHost( navController = navController, startDestination = startDestination.route, @@ -50,7 +53,16 @@ fun NavigationAppHost( ObserveAsEvents(viewModel.action){ action -> when(action){ is NewsAction.ShowSnackBar -> { - onShowSnackBar(context.getString(action.message), action.duration) + scope.launch { + val result = onShowSnackBar( + context.getString(action.message), + action.duration, + action.actionMessage?.let { msg -> context.getString(msg) }, + ) + if (result && action.actionMessage != null){ + viewModel.onEvent(NewsEvent.RetryRealTimeNewsSubscribe) + } + } } } } @@ -59,13 +71,7 @@ fun NavigationAppHost( news = uiState.news, mainNews = uiState.mainNews, realTimeNews = uiState.realTimeNews, - symbols = uiState.symbols, - sortBy = uiState.sortBy, - sortItems = uiState.sort, - endDate = uiState.endDateSort, - startDate = uiState.startDateSort, - startSelectableDates = uiState.startSelectableDates, - endSelectableDates = uiState.endSelectableDates, + filters = uiState.filters, isLoading = uiState.isLoading, isLoadingMoreNews = uiState.isLoadingMoreItems, isRefreshing = uiState.isRefreshing, diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/core/util/SelectableDatesImp.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/core/util/SelectableDatesImp.kt index 17a8e3d..83ec5c3 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/core/util/SelectableDatesImp.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/core/util/SelectableDatesImp.kt @@ -14,7 +14,7 @@ class SelectableDatesImp( override fun isSelectableDate(utcTimeMillis: Long): Boolean { val currentTimeInMillis = System.currentTimeMillis() val limitInMillis = when(dateMomentType){ - DateMomentType.End -> limitDate?.toEpochMillis(endOfTheDay = true) ?: utcTimeMillis + DateMomentType.End -> limitDate?.toEpochMillis(endOfTheDay = false) ?: utcTimeMillis DateMomentType.Start -> limitDate?.toEpochMillis(endOfTheDay = true) ?: currentTimeInMillis } return if (dateMomentType == DateMomentType.Start) utcTimeMillis <= limitInMillis diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsAction.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsAction.kt index 46aae29..05fb0be 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsAction.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsAction.kt @@ -7,5 +7,6 @@ sealed class NewsAction{ data class ShowSnackBar( @StringRes val message: Int, val duration: SnackbarDuration = SnackbarDuration.Short, + @StringRes val actionMessage: Int? = null, ): NewsAction() } diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsEvent.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsEvent.kt index 511c068..e43aa23 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsEvent.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsEvent.kt @@ -6,8 +6,12 @@ import dev.pinkroom.marketsight.domain.model.common.SubInfoSymbols sealed class NewsEvent { data object RetryNews: NewsEvent() + data object RetryRealTimeNewsSubscribe: NewsEvent() data object RefreshNews: NewsEvent() data object LoadMoreNews: NewsEvent() + data object ClearAllFilters: NewsEvent() + data object RevertFilters: NewsEvent() + data object ApplyFilters: NewsEvent() data class ShowOrHideFilters(val isToShow: Boolean? = null): NewsEvent() data class ChangeSort(val sort: SortType): NewsEvent() data class ChangeSymbol(val symbolToChange: SubInfoSymbols): NewsEvent() diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsScreen.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsScreen.kt index 59f12a5..3ea08bf 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsScreen.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsScreen.kt @@ -10,27 +10,27 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.SelectableDates +import androidx.compose.material3.SheetState import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.tooling.preview.Preview import dev.pinkroom.marketsight.R import dev.pinkroom.marketsight.common.Constants.BUFFER_LIST -import dev.pinkroom.marketsight.common.DateMomentType import dev.pinkroom.marketsight.common.SortType import dev.pinkroom.marketsight.domain.model.common.SubInfoSymbols import dev.pinkroom.marketsight.domain.model.news.ImageSize import dev.pinkroom.marketsight.domain.model.news.ImagesNews +import dev.pinkroom.marketsight.domain.model.news.NewsFilters import dev.pinkroom.marketsight.domain.model.news.NewsInfo import dev.pinkroom.marketsight.ui.core.components.PullToRefreshLazyColumn import dev.pinkroom.marketsight.ui.core.theme.dimens -import dev.pinkroom.marketsight.ui.core.util.SelectableDatesImp import dev.pinkroom.marketsight.ui.core.util.reachedBottom import dev.pinkroom.marketsight.ui.news_screen.components.AllNews import dev.pinkroom.marketsight.ui.news_screen.components.BottomSheetFilters @@ -38,7 +38,8 @@ import dev.pinkroom.marketsight.ui.news_screen.components.EmptyNewsList import dev.pinkroom.marketsight.ui.news_screen.components.HeaderListNews import dev.pinkroom.marketsight.ui.news_screen.components.MainNews import dev.pinkroom.marketsight.ui.news_screen.components.RealTimeNews -import java.time.LocalDate +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch import java.time.LocalDateTime @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @@ -48,13 +49,7 @@ fun NewsScreen( mainNews: List, news: List, realTimeNews: List, - symbols: List, - sortBy: SortType, - sortItems: List, - startDate: LocalDate? = null, - endDate: LocalDate? = null, - startSelectableDates: SelectableDates, - endSelectableDates: SelectableDates, + filters: NewsFilters, isLoading: Boolean, isLoadingMoreNews: Boolean, isRefreshing: Boolean, @@ -63,6 +58,7 @@ fun NewsScreen( onEvent: (event: NewsEvent) -> Unit, ){ val context = LocalContext.current + val scope = rememberCoroutineScope() val sheetState = rememberModalBottomSheetState() @@ -158,25 +154,31 @@ fun NewsScreen( .fillMaxHeight(dimens.bottomSheetHeight), isVisible = isToShowFilters, onDismiss = { - onEvent(NewsEvent.ShowOrHideFilters(isToShow = false)) + onEvent(NewsEvent.RevertFilters) }, sheetState = sheetState, - sortFilters = sortItems, - selectedSort = sortBy, + sortFilters = filters.sort, + selectedSort = filters.sortBy, onSortClick = { onEvent(NewsEvent.ChangeSort(sort = it)) }, - symbols = symbols, + symbols = filters.symbols, onSymbolClick = { onEvent(NewsEvent.ChangeSymbol(symbolToChange = it)) }, - startDate = startDate, - endDate = endDate, + startDate = filters.startDateSort, + endDate = filters.endDateSort, changeDate = { dateInMillis, dateMomentType -> onEvent(NewsEvent.ChangeDate(newDateInMillis = dateInMillis, dateMomentType = dateMomentType)) }, - startSelectableDates = startSelectableDates, - endSelectableDates = endSelectableDates, + onClearAll = { + sheetState.close(scope) + onEvent(NewsEvent.ClearAllFilters) + }, + onApply = { + sheetState.close(scope) + onEvent(NewsEvent.ApplyFilters) + }, ) } @@ -186,6 +188,8 @@ fun Context.navigateToNews(newsInfo: NewsInfo){ } @OptIn(ExperimentalMaterial3Api::class) +fun SheetState.close(scope: CoroutineScope) = scope.launch { hide() } + @Preview( showBackground = true, showSystemUi = true, @@ -291,23 +295,23 @@ fun NewsScreenPreview(){ headline = "Market Clubhouse Morning Memo - April 11th, 2024 (Trade Strategy For SPY, QQQ, AAPL, MSFT, NVDA, GOOGL, META, And TSLA)", ), ), - symbols = listOf( - SubInfoSymbols( - name = "TESLA", symbol = "TSLA", - ), - SubInfoSymbols( - name = "APPLE", symbol = "AAPL", + filters = NewsFilters( + symbols = listOf( + SubInfoSymbols( + name = "TESLA", symbol = "TSLA", + ), + SubInfoSymbols( + name = "APPLE", symbol = "AAPL", + ), ), + sort = listOf(), + sortBy = SortType.DESC, ), isLoading = false, isLoadingMoreNews = false, errorMessage = R.string.get_news_error_message, isRefreshing = false, isToShowFilters = false, - sortItems = listOf(), - sortBy = SortType.DESC, onEvent = {}, - startSelectableDates = SelectableDatesImp(dateMomentType = DateMomentType.Start), - endSelectableDates = SelectableDatesImp(dateMomentType = DateMomentType.End), ) } \ No newline at end of file diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsUiState.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsUiState.kt index 5f46aa5..0198c3c 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsUiState.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsUiState.kt @@ -1,13 +1,8 @@ package dev.pinkroom.marketsight.ui.news_screen import androidx.annotation.DrawableRes -import dev.pinkroom.marketsight.common.DateMomentType -import dev.pinkroom.marketsight.common.SortType -import dev.pinkroom.marketsight.common.popularSymbols -import dev.pinkroom.marketsight.domain.model.common.SubInfoSymbols +import dev.pinkroom.marketsight.domain.model.news.NewsFilters import dev.pinkroom.marketsight.domain.model.news.NewsInfo -import dev.pinkroom.marketsight.ui.core.util.SelectableDatesImp -import java.time.LocalDate data class NewsUiState( val isLoading: Boolean = true, @@ -18,13 +13,7 @@ data class NewsUiState( val mainNews: List = listOf(), val news: List = listOf(), val realTimeNews: List = listOf(), - val symbols: List = popularSymbols, - val sort: List = listOf(SortType.DESC, SortType.ASC), - val sortBy: SortType = SortType.DESC, - val startDateSort: LocalDate? = null, - val endDateSort: LocalDate? = null, - val startSelectableDates: SelectableDatesImp = SelectableDatesImp(dateMomentType = DateMomentType.Start), - val endSelectableDates: SelectableDatesImp = SelectableDatesImp(dateMomentType = DateMomentType.End) + val filters: NewsFilters = NewsFilters(), ) diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsViewModel.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsViewModel.kt index 682d4d6..1bd8b49 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsViewModel.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsViewModel.kt @@ -1,5 +1,6 @@ package dev.pinkroom.marketsight.ui.news_screen +import androidx.compose.material3.SnackbarDuration import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel @@ -10,17 +11,18 @@ import dev.pinkroom.marketsight.common.DateMomentType import dev.pinkroom.marketsight.common.DispatcherProvider import dev.pinkroom.marketsight.common.Resource import dev.pinkroom.marketsight.common.SortType +import dev.pinkroom.marketsight.common.atEndOfTheDay import dev.pinkroom.marketsight.common.connection_network.ConnectivityObserver import dev.pinkroom.marketsight.common.connection_network.ConnectivityObserver.Status.Available import dev.pinkroom.marketsight.common.connection_network.ConnectivityObserver.Status.Unavailable import dev.pinkroom.marketsight.common.paginator.DefaultPagination import dev.pinkroom.marketsight.domain.model.common.PaginationInfo import dev.pinkroom.marketsight.domain.model.common.SubInfoSymbols +import dev.pinkroom.marketsight.domain.model.news.NewsFilters import dev.pinkroom.marketsight.domain.model.news.NewsResponse import dev.pinkroom.marketsight.domain.use_case.news.ChangeFilterNews import dev.pinkroom.marketsight.domain.use_case.news.GetNews import dev.pinkroom.marketsight.domain.use_case.news.GetRealTimeNews -import dev.pinkroom.marketsight.ui.core.util.SelectableDatesImp import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow @@ -55,7 +57,14 @@ class NewsViewModel @Inject constructor( _uiState.update { it.copy(isLoadingMoreItems = isLoading) } }, onRequest = { nextPage -> - getNews(pageToken = nextPage) + val filters = uiState.value.filters + getNews( + pageToken = nextPage, + sortType = filters.sortBy, + symbols = filters.getSubscribedSymbols(), + startDate = filters.startDateSort?.atStartOfDay(), + endDate = filters.endDateSort?.atEndOfTheDay(), + ) }, getNextKey = { it.nextPageToken @@ -74,6 +83,7 @@ class NewsViewModel @Inject constructor( private var initNewsJob: Job? = null private var connectionStatus = Unavailable + private var previousFilters: NewsFilters = uiState.value.filters init { observeNetworkStatus() @@ -84,8 +94,12 @@ class NewsViewModel @Inject constructor( fun onEvent(event: NewsEvent) { when(event) { NewsEvent.RetryNews -> retryToGetNews() + NewsEvent.RetryRealTimeNewsSubscribe -> updateFiltersRealTimeNews(newFilters = previousFilters) NewsEvent.RefreshNews -> refreshNews() NewsEvent.LoadMoreNews -> loadMoreNews() + NewsEvent.ApplyFilters -> applyFilters() + NewsEvent.ClearAllFilters -> clearAllFilters() + NewsEvent.RevertFilters -> revertFilters() is NewsEvent.ShowOrHideFilters -> showOrHideFilters(isToShow = event.isToShow) is NewsEvent.ChangeSort -> changeSort(newSort = event.sort) is NewsEvent.ChangeSymbol -> changeSymbols(symbolToChange = event.symbolToChange) @@ -106,7 +120,15 @@ class NewsViewModel @Inject constructor( private fun initNews() { initNewsJob = viewModelScope.launch(dispatchers.IO) { _uiState.update { it.copy(isLoading = true) } - when(val response = getNews()){ + paginationInfo = paginationInfo.copy(endReached = false) + val filters = uiState.value.filters + val response = getNews( + sortType = filters.sortBy, + symbols = filters.getSubscribedSymbols(), + startDate = filters.startDateSort?.atStartOfDay(), + endDate = filters.endDateSort?.atEndOfTheDay(), + ) + when(response){ is Resource.Success -> { val allNews = response.data.news val maxNumberNews = if (allNews.size >= MAX_ITEMS_CAROUSEL) MAX_ITEMS_CAROUSEL else allNews.size @@ -152,7 +174,7 @@ class NewsViewModel @Inject constructor( private fun loadMoreNews() { viewModelScope.launch(dispatchers.IO) { - if (!paginationInfo.isLoading) pagination.loadNextItems() + if (!paginationInfo.isLoading && !paginationInfo.endReached) pagination.loadNextItems() } } @@ -170,19 +192,19 @@ class NewsViewModel @Inject constructor( } private fun changeSort(newSort: SortType) { - if (uiState.value.sortBy == newSort) return - _uiState.update { it.copy(sortBy = newSort) } + if (uiState.value.filters.sortBy == newSort) return + _uiState.update { it.copy(filters = it.filters.copy(sortBy = newSort)) } } private fun changeSymbols(symbolToChange: SubInfoSymbols) { - val symbolsSubscribed = uiState.value.symbols.filter { it.isSubscribed } + val symbolsSubscribed = uiState.value.filters.symbols.filter { it.isSubscribed } val totalSizeSubscribed = if (symbolsSubscribed.contains(symbolToChange)) symbolsSubscribed.size - 1 else symbolsSubscribed.size val needToSubscribeAll = totalSizeSubscribed == 0 || symbolToChange.name == ALL_SYMBOLS - val newListSymbols = uiState.value.symbols.map { item -> + val newListSymbols = uiState.value.filters.symbols.map { item -> when { item.name == ALL_SYMBOLS -> item.copy(isSubscribed = needToSubscribeAll) item.symbol == symbolToChange.symbol -> item.copy(isSubscribed = !item.isSubscribed) @@ -190,26 +212,59 @@ class NewsViewModel @Inject constructor( else -> item.copy() } } - _uiState.update { it.copy(symbols = newListSymbols) } + _uiState.update { it.copy(filters = it.filters.copy(symbols = newListSymbols)) } } - private fun changeDate(dateInMillis: Long?, dateMomentType: DateMomentType){ + private fun changeDate(dateInMillis: Long?, dateMomentType: DateMomentType) { val date = dateInMillis?.let { Instant.ofEpochMilli(it).atZone(ZoneOffset.UTC).toLocalDate() } when(dateMomentType){ DateMomentType.End -> { - _uiState.update { - it.copy( - endDateSort = date, - startSelectableDates = SelectableDatesImp(limitDate = date, dateMomentType = DateMomentType.Start), - ) - } + _uiState.update { it.copy(filters = it.filters.copy(endDateSort = date)) } } DateMomentType.Start -> { - _uiState.update { - it.copy( - startDateSort = date, - endSelectableDates = SelectableDatesImp(limitDate = date, dateMomentType = DateMomentType.End), - ) + _uiState.update { it.copy(filters = it.filters.copy(startDateSort = date)) } + } + } + } + + private fun applyFilters() { + _uiState.update { it.copy(isToShowFilters = false) } + if (previousFilters != uiState.value.filters) + updateNewsWithNewFilters(newFilters = uiState.value.filters) + } + + private fun clearAllFilters() { + val baseFilters = NewsFilters() + if (previousFilters != baseFilters) updateNewsWithNewFilters(newFilters = baseFilters) + _uiState.update { it.copy(filters = baseFilters, isToShowFilters = false) } + } + + private fun revertFilters() = _uiState.update { it.copy(filters = previousFilters, isToShowFilters = false) } + + private fun updateNewsWithNewFilters(newFilters: NewsFilters){ + initNews() + if (previousFilters.symbols != newFilters.symbols) updateFiltersRealTimeNews(newFilters) + previousFilters = newFilters + } + + private fun updateFiltersRealTimeNews(newFilters: NewsFilters){ + viewModelScope.launch(dispatchers.IO) { + _uiState.update { it.copy(realTimeNews = emptyList()) } + changeFilterNews( + subscribeSymbols = newFilters.symbols.filter { it.isSubscribed }.map { it.symbol }, + unsubscribeSymbols = newFilters.symbols.filter { !it.isSubscribed }.map { it.symbol } + ).collect{ response -> + when(response){ + is Resource.Success -> Unit + is Resource.Error -> { + _action.send( + NewsAction.ShowSnackBar( + message = R.string.error_subscription_real_time_news, + duration = SnackbarDuration.Indefinite, + actionMessage = R.string.retry, + ) + ) + } } } } diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/BottomSheetFilters.kt b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/BottomSheetFilters.kt index 3fc8193..0d218d7 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/BottomSheetFilters.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/BottomSheetFilters.kt @@ -1,10 +1,14 @@ package dev.pinkroom.marketsight.ui.news_screen.components import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -20,9 +24,11 @@ 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.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import dev.pinkroom.marketsight.R import dev.pinkroom.marketsight.common.DateMomentType import dev.pinkroom.marketsight.common.SortType @@ -31,10 +37,12 @@ import dev.pinkroom.marketsight.domain.model.common.SubInfoSymbols import dev.pinkroom.marketsight.ui.core.components.ButtonFilter import dev.pinkroom.marketsight.ui.core.components.DatePickerComponent import dev.pinkroom.marketsight.ui.core.components.DefaultSectionFilter +import dev.pinkroom.marketsight.ui.core.theme.Blue import dev.pinkroom.marketsight.ui.core.theme.dimens +import dev.pinkroom.marketsight.ui.core.util.SelectableDatesImp import java.time.LocalDate -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun BottomSheetFilters( modifier: Modifier = Modifier, @@ -48,8 +56,8 @@ fun BottomSheetFilters( onSortClick: (sort: SortType) -> Unit, onSymbolClick: (symbol: SubInfoSymbols) -> Unit, changeDate: (dateInMillis: Long?, dateMomentType: DateMomentType) -> Unit, - startSelectableDates: SelectableDates, - endSelectableDates: SelectableDates, + onClearAll: () -> Unit, + onApply: () -> Unit, sheetState: SheetState, ){ AnimatedVisibility(visible = isVisible) { @@ -65,14 +73,15 @@ fun BottomSheetFilters( bottom = dimens.normalPadding, ) ) { - item { - Text( + stickyHeader { + HeaderBottomSheetFilters( modifier = Modifier + .background(color = MaterialTheme.colorScheme.background) .fillMaxWidth() - .padding(horizontal = dimens.horizontalPadding), - text = stringResource(id = R.string.filters_news), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, + .padding(horizontal = dimens.horizontalPadding) + .padding(bottom = dimens.smallPadding), + onApply = onApply, + onClearAll = onClearAll, ) } item { @@ -80,8 +89,8 @@ fun BottomSheetFilters( startDate = startDate, endDate = endDate, changeDate = changeDate, - startSelectableDates = startSelectableDates, - endSelectableDates = endSelectableDates, + startSelectableDates = SelectableDatesImp(dateMomentType = DateMomentType.Start, limitDate = endDate), + endSelectableDates = SelectableDatesImp(dateMomentType = DateMomentType.End, limitDate = startDate), ) } item { @@ -106,6 +115,42 @@ fun BottomSheetFilters( } } +@Composable +fun HeaderBottomSheetFilters( + modifier: Modifier = Modifier, + onClearAll: () -> Unit, + onApply: () -> Unit, +){ + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Absolute.SpaceBetween, + ) { + Text( + modifier = Modifier + .clickable( + onClick = onClearAll, + ), + text = stringResource(id = R.string.clear_all) + ) + Text( + text = stringResource(id = R.string.filters_news), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + ) + Text( + modifier = Modifier + .clickable( + onClick = onApply, + ), + text = stringResource(id = R.string.apply), + textAlign = TextAlign.End, + color = Blue, + ) + } +} + @OptIn(ExperimentalLayoutApi::class) @Composable fun SortSection( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f089291..8900f77 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -21,4 +21,7 @@ Start Date End Date Clear Date + Clear All + Apply + An error occurred when trying to get news in real time. \ No newline at end of file From 9a765df1c4f02d82d12ff497767301257cdcb765 Mon Sep 17 00:00:00 2001 From: LucasPrioste Date: Mon, 29 Apr 2024 16:54:59 +0100 Subject: [PATCH 22/24] - unit test --- app/build.gradle.kts | 1 + app/src/main/AndroidManifest.xml | 2 +- .../data/repository/NewsRepositoryImp.kt | 2 +- .../domain/repository/NewsRepository.kt | 2 +- ...terNews.kt => ChangeFilterRealTimeNews.kt} | 6 +- .../{ui => presentation}/MainActivity.kt | 10 +- .../core/components/ButtonFilter.kt | 8 +- .../core/components/DatePickerComponent.kt | 8 +- .../core/components/DefaultSectionFilter.kt | 4 +- .../components/PullToRefreshLazyColumn.kt | 2 +- .../core/navigation/BottomBarItem.kt | 2 +- .../core/navigation/NavigationAppHost.kt | 20 +- .../core/navigation/NavigationBottomBar.kt | 4 +- .../core/navigation/Route.kt | 4 +- .../{ui => presentation}/core/theme/Color.kt | 2 +- .../core/theme/Dimensions.kt | 2 +- .../core/theme/ShimmerEffect.kt | 2 +- .../{ui => presentation}/core/theme/Theme.kt | 2 +- .../{ui => presentation}/core/theme/Type.kt | 2 +- .../core/util/ObserveAsEvents.kt | 2 +- .../core/util/ReachedBottom.kt | 2 +- .../core/util/SelectableDatesImp.kt | 2 +- .../detail_screen/DetailScreen.kt | 2 +- .../home_screen/HomeScreen.kt | 2 +- .../home_screen/HomeViewModel.kt | 2 +- .../news_screen/NewsAction.kt | 2 +- .../news_screen/NewsEvent.kt | 2 +- .../news_screen/NewsScreen.kt | 20 +- .../news_screen/NewsUiState.kt | 2 +- .../news_screen/NewsViewModel.kt | 15 +- .../news_screen/components/AllNews.kt | 6 +- .../news_screen/components/AllNewsCard.kt | 4 +- .../components/BottomSheetFilters.kt | 14 +- .../news_screen/components/EmptyNewsList.kt | 4 +- .../news_screen/components/HeaderListNews.kt | 4 +- .../news_screen/components/ImageNews.kt | 4 +- .../components/IndicatorPageCarousel.kt | 8 +- .../news_screen/components/MainNews.kt | 6 +- .../news_screen/components/MainNewsCard.kt | 8 +- .../news_screen/components/RealTimeNews.kt | 6 +- .../components/RealTimeNewsCard.kt | 4 +- .../data_source/NewsRemoteDataSourceTest.kt | 34 +- .../data/repository/NewsRepositoryTest.kt | 25 +- ...est.kt => ChangeFilterRealTimeNewsTest.kt} | 20 +- .../news_screen/NewsViewModelTest.kt | 678 ++++++++++++++++++ gradle/libs.versions.toml | 4 + 46 files changed, 816 insertions(+), 151 deletions(-) rename app/src/main/java/dev/pinkroom/marketsight/domain/use_case/news/{ChangeFilterNews.kt => ChangeFilterRealTimeNews.kt} (81%) rename app/src/main/java/dev/pinkroom/marketsight/{ui => presentation}/MainActivity.kt (88%) rename app/src/main/java/dev/pinkroom/marketsight/{ui => presentation}/core/components/ButtonFilter.kt (89%) rename app/src/main/java/dev/pinkroom/marketsight/{ui => presentation}/core/components/DatePickerComponent.kt (94%) rename app/src/main/java/dev/pinkroom/marketsight/{ui => presentation}/core/components/DefaultSectionFilter.kt (89%) rename app/src/main/java/dev/pinkroom/marketsight/{ui => presentation}/core/components/PullToRefreshLazyColumn.kt (97%) rename app/src/main/java/dev/pinkroom/marketsight/{ui => presentation}/core/navigation/BottomBarItem.kt (92%) rename app/src/main/java/dev/pinkroom/marketsight/{ui => presentation}/core/navigation/NavigationAppHost.kt (81%) rename app/src/main/java/dev/pinkroom/marketsight/{ui => presentation}/core/navigation/NavigationBottomBar.kt (97%) rename app/src/main/java/dev/pinkroom/marketsight/{ui => presentation}/core/navigation/Route.kt (75%) rename app/src/main/java/dev/pinkroom/marketsight/{ui => presentation}/core/theme/Color.kt (89%) rename app/src/main/java/dev/pinkroom/marketsight/{ui => presentation}/core/theme/Dimensions.kt (98%) rename app/src/main/java/dev/pinkroom/marketsight/{ui => presentation}/core/theme/ShimmerEffect.kt (96%) rename app/src/main/java/dev/pinkroom/marketsight/{ui => presentation}/core/theme/Theme.kt (97%) rename app/src/main/java/dev/pinkroom/marketsight/{ui => presentation}/core/theme/Type.kt (98%) rename app/src/main/java/dev/pinkroom/marketsight/{ui => presentation}/core/util/ObserveAsEvents.kt (90%) rename app/src/main/java/dev/pinkroom/marketsight/{ui => presentation}/core/util/ReachedBottom.kt (84%) rename app/src/main/java/dev/pinkroom/marketsight/{ui => presentation}/core/util/SelectableDatesImp.kt (95%) rename app/src/main/java/dev/pinkroom/marketsight/{ui => presentation}/detail_screen/DetailScreen.kt (91%) rename app/src/main/java/dev/pinkroom/marketsight/{ui => presentation}/home_screen/HomeScreen.kt (91%) rename app/src/main/java/dev/pinkroom/marketsight/{ui => presentation}/home_screen/HomeViewModel.kt (76%) rename app/src/main/java/dev/pinkroom/marketsight/{ui => presentation}/news_screen/NewsAction.kt (84%) rename app/src/main/java/dev/pinkroom/marketsight/{ui => presentation}/news_screen/NewsEvent.kt (93%) rename app/src/main/java/dev/pinkroom/marketsight/{ui => presentation}/news_screen/NewsScreen.kt (94%) rename app/src/main/java/dev/pinkroom/marketsight/{ui => presentation}/news_screen/NewsUiState.kt (90%) rename app/src/main/java/dev/pinkroom/marketsight/{ui => presentation}/news_screen/NewsViewModel.kt (98%) rename app/src/main/java/dev/pinkroom/marketsight/{ui => presentation}/news_screen/components/AllNews.kt (93%) rename app/src/main/java/dev/pinkroom/marketsight/{ui => presentation}/news_screen/components/AllNewsCard.kt (97%) rename app/src/main/java/dev/pinkroom/marketsight/{ui => presentation}/news_screen/components/BottomSheetFilters.kt (95%) rename app/src/main/java/dev/pinkroom/marketsight/{ui => presentation}/news_screen/components/EmptyNewsList.kt (92%) rename app/src/main/java/dev/pinkroom/marketsight/{ui => presentation}/news_screen/components/HeaderListNews.kt (93%) rename app/src/main/java/dev/pinkroom/marketsight/{ui => presentation}/news_screen/components/ImageNews.kt (88%) rename app/src/main/java/dev/pinkroom/marketsight/{ui => presentation}/news_screen/components/IndicatorPageCarousel.kt (91%) rename app/src/main/java/dev/pinkroom/marketsight/{ui => presentation}/news_screen/components/MainNews.kt (95%) rename app/src/main/java/dev/pinkroom/marketsight/{ui => presentation}/news_screen/components/MainNewsCard.kt (95%) rename app/src/main/java/dev/pinkroom/marketsight/{ui => presentation}/news_screen/components/RealTimeNews.kt (95%) rename app/src/main/java/dev/pinkroom/marketsight/{ui => presentation}/news_screen/components/RealTimeNewsCard.kt (96%) rename app/src/test/java/dev/pinkroom/marketsight/domain/use_case/news/{ChangeFilterNewsTest.kt => ChangeFilterRealTimeNewsTest.kt} (86%) create mode 100644 app/src/test/java/dev/pinkroom/marketsight/presentation/news_screen/NewsViewModelTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6e9cf7e..38a162b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -126,6 +126,7 @@ dependencies { testImplementation(libs.coroutine.test) testImplementation(libs.assertk) testImplementation(libs.faker) + testImplementation(libs.turbine) // END-TO-END TEST androidTestImplementation(libs.androidx.junit) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6396822..6d0f573 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -17,7 +17,7 @@ tools:targetApi="34" android:name=".MarketSightApp"> diff --git a/app/src/main/java/dev/pinkroom/marketsight/data/repository/NewsRepositoryImp.kt b/app/src/main/java/dev/pinkroom/marketsight/data/repository/NewsRepositoryImp.kt index f91001c..f51832c 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/data/repository/NewsRepositoryImp.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/data/repository/NewsRepositoryImp.kt @@ -27,7 +27,7 @@ class NewsRepositoryImp @Inject constructor( } }.flowOn(dispatchers.IO) - override suspend fun changeFilterNews( + override suspend fun changeFilterRealTimeNews( symbols: List, actionAlpaca: ActionAlpaca, ): Resource> = flow { diff --git a/app/src/main/java/dev/pinkroom/marketsight/domain/repository/NewsRepository.kt b/app/src/main/java/dev/pinkroom/marketsight/domain/repository/NewsRepository.kt index c0d39de..bb64922 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/domain/repository/NewsRepository.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/domain/repository/NewsRepository.kt @@ -11,7 +11,7 @@ import java.time.LocalDateTime interface NewsRepository { fun getRealTimeNews(): Flow> - suspend fun changeFilterNews( + suspend fun changeFilterRealTimeNews( symbols: List, actionAlpaca: ActionAlpaca, ): Resource> diff --git a/app/src/main/java/dev/pinkroom/marketsight/domain/use_case/news/ChangeFilterNews.kt b/app/src/main/java/dev/pinkroom/marketsight/domain/use_case/news/ChangeFilterRealTimeNews.kt similarity index 81% rename from app/src/main/java/dev/pinkroom/marketsight/domain/use_case/news/ChangeFilterNews.kt rename to app/src/main/java/dev/pinkroom/marketsight/domain/use_case/news/ChangeFilterRealTimeNews.kt index cc0ec2e..6655c89 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/domain/use_case/news/ChangeFilterNews.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/domain/use_case/news/ChangeFilterRealTimeNews.kt @@ -8,7 +8,7 @@ import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import javax.inject.Inject -class ChangeFilterNews @Inject constructor( +class ChangeFilterRealTimeNews @Inject constructor( private val newsRepository: NewsRepository, private val dispatchers: DispatcherProvider, ){ @@ -18,7 +18,7 @@ class ChangeFilterNews @Inject constructor( ) = flow>> { val symbolsToRevert = mutableListOf() unsubscribeSymbols?.let { symbols -> - val response = newsRepository.changeFilterNews(symbols = symbols, actionAlpaca = ActionAlpaca.Unsubscribe) + val response = newsRepository.changeFilterRealTimeNews(symbols = symbols, actionAlpaca = ActionAlpaca.Unsubscribe) when(response){ is Resource.Error -> { symbolsToRevert.addAll(unsubscribeSymbols) @@ -28,7 +28,7 @@ class ChangeFilterNews @Inject constructor( } subscribeSymbols?.let { symbols -> - val response = newsRepository.changeFilterNews(symbols = symbols, actionAlpaca = ActionAlpaca.Subscribe) + val response = newsRepository.changeFilterRealTimeNews(symbols = symbols, actionAlpaca = ActionAlpaca.Subscribe) when(response){ is Resource.Success -> emit(Resource.Success(data = response.data)) is Resource.Error -> { diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/MainActivity.kt b/app/src/main/java/dev/pinkroom/marketsight/presentation/MainActivity.kt similarity index 88% rename from app/src/main/java/dev/pinkroom/marketsight/ui/MainActivity.kt rename to app/src/main/java/dev/pinkroom/marketsight/presentation/MainActivity.kt index e1b8b4f..d9d66a3 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/MainActivity.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/presentation/MainActivity.kt @@ -1,4 +1,4 @@ -package dev.pinkroom.marketsight.ui +package dev.pinkroom.marketsight.presentation import android.os.Bundle import androidx.activity.ComponentActivity @@ -15,10 +15,10 @@ import androidx.compose.ui.Modifier import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.navigation.compose.rememberNavController import dagger.hilt.android.AndroidEntryPoint -import dev.pinkroom.marketsight.ui.core.navigation.NavigationAppHost -import dev.pinkroom.marketsight.ui.core.navigation.NavigationBottomBar -import dev.pinkroom.marketsight.ui.core.navigation.Route -import dev.pinkroom.marketsight.ui.core.theme.MarketSightTheme +import dev.pinkroom.marketsight.presentation.core.navigation.NavigationAppHost +import dev.pinkroom.marketsight.presentation.core.navigation.NavigationBottomBar +import dev.pinkroom.marketsight.presentation.core.navigation.Route +import dev.pinkroom.marketsight.presentation.core.theme.MarketSightTheme @AndroidEntryPoint class MainActivity : ComponentActivity() { diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/core/components/ButtonFilter.kt b/app/src/main/java/dev/pinkroom/marketsight/presentation/core/components/ButtonFilter.kt similarity index 89% rename from app/src/main/java/dev/pinkroom/marketsight/ui/core/components/ButtonFilter.kt rename to app/src/main/java/dev/pinkroom/marketsight/presentation/core/components/ButtonFilter.kt index 1da14ea..3f7bcd4 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/core/components/ButtonFilter.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/presentation/core/components/ButtonFilter.kt @@ -1,4 +1,4 @@ -package dev.pinkroom.marketsight.ui.core.components +package dev.pinkroom.marketsight.presentation.core.components import androidx.compose.animation.animateContentSize import androidx.compose.foundation.BorderStroke @@ -17,9 +17,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import dev.pinkroom.marketsight.R -import dev.pinkroom.marketsight.ui.core.theme.BabyBlue -import dev.pinkroom.marketsight.ui.core.theme.Blue -import dev.pinkroom.marketsight.ui.core.theme.dimens +import dev.pinkroom.marketsight.presentation.core.theme.BabyBlue +import dev.pinkroom.marketsight.presentation.core.theme.Blue +import dev.pinkroom.marketsight.presentation.core.theme.dimens @Composable fun ButtonFilter( diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/core/components/DatePickerComponent.kt b/app/src/main/java/dev/pinkroom/marketsight/presentation/core/components/DatePickerComponent.kt similarity index 94% rename from app/src/main/java/dev/pinkroom/marketsight/ui/core/components/DatePickerComponent.kt rename to app/src/main/java/dev/pinkroom/marketsight/presentation/core/components/DatePickerComponent.kt index 90af288..84d011b 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/core/components/DatePickerComponent.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/presentation/core/components/DatePickerComponent.kt @@ -1,4 +1,4 @@ -package dev.pinkroom.marketsight.ui.core.components +package dev.pinkroom.marketsight.presentation.core.components import android.annotation.SuppressLint import androidx.compose.animation.AnimatedVisibility @@ -21,9 +21,9 @@ import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.res.stringResource import dev.pinkroom.marketsight.R import dev.pinkroom.marketsight.common.toEpochMillis -import dev.pinkroom.marketsight.ui.core.theme.BabyBlue -import dev.pinkroom.marketsight.ui.core.theme.Blue -import dev.pinkroom.marketsight.ui.core.theme.dimens +import dev.pinkroom.marketsight.presentation.core.theme.BabyBlue +import dev.pinkroom.marketsight.presentation.core.theme.Blue +import dev.pinkroom.marketsight.presentation.core.theme.dimens import java.time.LocalDate @SuppressLint("UnrememberedMutableState") diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/core/components/DefaultSectionFilter.kt b/app/src/main/java/dev/pinkroom/marketsight/presentation/core/components/DefaultSectionFilter.kt similarity index 89% rename from app/src/main/java/dev/pinkroom/marketsight/ui/core/components/DefaultSectionFilter.kt rename to app/src/main/java/dev/pinkroom/marketsight/presentation/core/components/DefaultSectionFilter.kt index 5005aed..ddafd69 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/core/components/DefaultSectionFilter.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/presentation/core/components/DefaultSectionFilter.kt @@ -1,4 +1,4 @@ -package dev.pinkroom.marketsight.ui.core.components +package dev.pinkroom.marketsight.presentation.core.components import androidx.compose.foundation.layout.padding import androidx.compose.material3.HorizontalDivider @@ -8,7 +8,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign -import dev.pinkroom.marketsight.ui.core.theme.dimens +import dev.pinkroom.marketsight.presentation.core.theme.dimens @Composable fun DefaultSectionFilter( diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/core/components/PullToRefreshLazyColumn.kt b/app/src/main/java/dev/pinkroom/marketsight/presentation/core/components/PullToRefreshLazyColumn.kt similarity index 97% rename from app/src/main/java/dev/pinkroom/marketsight/ui/core/components/PullToRefreshLazyColumn.kt rename to app/src/main/java/dev/pinkroom/marketsight/presentation/core/components/PullToRefreshLazyColumn.kt index 9f36a0e..a7054de 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/core/components/PullToRefreshLazyColumn.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/presentation/core/components/PullToRefreshLazyColumn.kt @@ -1,4 +1,4 @@ -package dev.pinkroom.marketsight.ui.core.components +package dev.pinkroom.marketsight.presentation.core.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/BottomBarItem.kt b/app/src/main/java/dev/pinkroom/marketsight/presentation/core/navigation/BottomBarItem.kt similarity index 92% rename from app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/BottomBarItem.kt rename to app/src/main/java/dev/pinkroom/marketsight/presentation/core/navigation/BottomBarItem.kt index 8968ef9..ea06914 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/BottomBarItem.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/presentation/core/navigation/BottomBarItem.kt @@ -1,4 +1,4 @@ -package dev.pinkroom.marketsight.ui.core.navigation +package dev.pinkroom.marketsight.presentation.core.navigation import androidx.annotation.DrawableRes import androidx.annotation.StringRes diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/NavigationAppHost.kt b/app/src/main/java/dev/pinkroom/marketsight/presentation/core/navigation/NavigationAppHost.kt similarity index 81% rename from app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/NavigationAppHost.kt rename to app/src/main/java/dev/pinkroom/marketsight/presentation/core/navigation/NavigationAppHost.kt index 72b9330..804e920 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/NavigationAppHost.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/presentation/core/navigation/NavigationAppHost.kt @@ -1,4 +1,4 @@ -package dev.pinkroom.marketsight.ui.core.navigation +package dev.pinkroom.marketsight.presentation.core.navigation import androidx.compose.material3.SnackbarDuration import androidx.compose.runtime.Composable @@ -12,15 +12,15 @@ import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.navArgument -import dev.pinkroom.marketsight.ui.core.navigation.Args.SYMBOL_ID -import dev.pinkroom.marketsight.ui.core.util.ObserveAsEvents -import dev.pinkroom.marketsight.ui.detail_screen.DetailScreen -import dev.pinkroom.marketsight.ui.home_screen.HomeScreen -import dev.pinkroom.marketsight.ui.home_screen.HomeViewModel -import dev.pinkroom.marketsight.ui.news_screen.NewsAction -import dev.pinkroom.marketsight.ui.news_screen.NewsEvent -import dev.pinkroom.marketsight.ui.news_screen.NewsScreen -import dev.pinkroom.marketsight.ui.news_screen.NewsViewModel +import dev.pinkroom.marketsight.presentation.core.navigation.Args.SYMBOL_ID +import dev.pinkroom.marketsight.presentation.core.util.ObserveAsEvents +import dev.pinkroom.marketsight.presentation.detail_screen.DetailScreen +import dev.pinkroom.marketsight.presentation.home_screen.HomeScreen +import dev.pinkroom.marketsight.presentation.home_screen.HomeViewModel +import dev.pinkroom.marketsight.presentation.news_screen.NewsAction +import dev.pinkroom.marketsight.presentation.news_screen.NewsEvent +import dev.pinkroom.marketsight.presentation.news_screen.NewsScreen +import dev.pinkroom.marketsight.presentation.news_screen.NewsViewModel import kotlinx.coroutines.launch @Composable diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/NavigationBottomBar.kt b/app/src/main/java/dev/pinkroom/marketsight/presentation/core/navigation/NavigationBottomBar.kt similarity index 97% rename from app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/NavigationBottomBar.kt rename to app/src/main/java/dev/pinkroom/marketsight/presentation/core/navigation/NavigationBottomBar.kt index 1dedf4b..2d89961 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/NavigationBottomBar.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/presentation/core/navigation/NavigationBottomBar.kt @@ -1,4 +1,4 @@ -package dev.pinkroom.marketsight.ui.core.navigation +package dev.pinkroom.marketsight.presentation.core.navigation import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.animateDpAsState @@ -33,7 +33,7 @@ import androidx.compose.ui.res.stringResource import androidx.navigation.NavHostController import androidx.navigation.compose.currentBackStackEntryAsState import dev.pinkroom.marketsight.R -import dev.pinkroom.marketsight.ui.core.theme.dimens +import dev.pinkroom.marketsight.presentation.core.theme.dimens @Composable fun NavigationBottomBar( diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/Route.kt b/app/src/main/java/dev/pinkroom/marketsight/presentation/core/navigation/Route.kt similarity index 75% rename from app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/Route.kt rename to app/src/main/java/dev/pinkroom/marketsight/presentation/core/navigation/Route.kt index a21306a..32cd4d7 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/core/navigation/Route.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/presentation/core/navigation/Route.kt @@ -1,6 +1,6 @@ -package dev.pinkroom.marketsight.ui.core.navigation +package dev.pinkroom.marketsight.presentation.core.navigation -import dev.pinkroom.marketsight.ui.core.navigation.Args.SYMBOL_ID +import dev.pinkroom.marketsight.presentation.core.navigation.Args.SYMBOL_ID sealed class Route(val route: String){ data object HomeScreen: Route("home-screen") diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Color.kt b/app/src/main/java/dev/pinkroom/marketsight/presentation/core/theme/Color.kt similarity index 89% rename from app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Color.kt rename to app/src/main/java/dev/pinkroom/marketsight/presentation/core/theme/Color.kt index ae88b88..4f1069f 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Color.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/presentation/core/theme/Color.kt @@ -1,4 +1,4 @@ -package dev.pinkroom.marketsight.ui.core.theme +package dev.pinkroom.marketsight.presentation.core.theme import androidx.compose.ui.graphics.Color diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Dimensions.kt b/app/src/main/java/dev/pinkroom/marketsight/presentation/core/theme/Dimensions.kt similarity index 98% rename from app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Dimensions.kt rename to app/src/main/java/dev/pinkroom/marketsight/presentation/core/theme/Dimensions.kt index 9e56965..129a7e5 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Dimensions.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/presentation/core/theme/Dimensions.kt @@ -1,4 +1,4 @@ -package dev.pinkroom.marketsight.ui.core.theme +package dev.pinkroom.marketsight.presentation.core.theme import androidx.compose.runtime.Composable import androidx.compose.ui.platform.LocalConfiguration diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/ShimmerEffect.kt b/app/src/main/java/dev/pinkroom/marketsight/presentation/core/theme/ShimmerEffect.kt similarity index 96% rename from app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/ShimmerEffect.kt rename to app/src/main/java/dev/pinkroom/marketsight/presentation/core/theme/ShimmerEffect.kt index 7ca4efc..29a1be4 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/ShimmerEffect.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/presentation/core/theme/ShimmerEffect.kt @@ -1,4 +1,4 @@ -package dev.pinkroom.marketsight.ui.core.theme +package dev.pinkroom.marketsight.presentation.core.theme import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.infiniteRepeatable diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Theme.kt b/app/src/main/java/dev/pinkroom/marketsight/presentation/core/theme/Theme.kt similarity index 97% rename from app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Theme.kt rename to app/src/main/java/dev/pinkroom/marketsight/presentation/core/theme/Theme.kt index 78e4467..4779b42 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Theme.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/presentation/core/theme/Theme.kt @@ -1,4 +1,4 @@ -package dev.pinkroom.marketsight.ui.core.theme +package dev.pinkroom.marketsight.presentation.core.theme import android.app.Activity import android.os.Build diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Type.kt b/app/src/main/java/dev/pinkroom/marketsight/presentation/core/theme/Type.kt similarity index 98% rename from app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Type.kt rename to app/src/main/java/dev/pinkroom/marketsight/presentation/core/theme/Type.kt index ba9b22c..1cacbfd 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/core/theme/Type.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/presentation/core/theme/Type.kt @@ -1,4 +1,4 @@ -package dev.pinkroom.marketsight.ui.core.theme +package dev.pinkroom.marketsight.presentation.core.theme import androidx.compose.material3.Typography import androidx.compose.runtime.Composable diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/core/util/ObserveAsEvents.kt b/app/src/main/java/dev/pinkroom/marketsight/presentation/core/util/ObserveAsEvents.kt similarity index 90% rename from app/src/main/java/dev/pinkroom/marketsight/ui/core/util/ObserveAsEvents.kt rename to app/src/main/java/dev/pinkroom/marketsight/presentation/core/util/ObserveAsEvents.kt index d181753..4e5b90f 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/core/util/ObserveAsEvents.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/presentation/core/util/ObserveAsEvents.kt @@ -1,4 +1,4 @@ -package dev.pinkroom.marketsight.ui.core.util +package dev.pinkroom.marketsight.presentation.core.util import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/core/util/ReachedBottom.kt b/app/src/main/java/dev/pinkroom/marketsight/presentation/core/util/ReachedBottom.kt similarity index 84% rename from app/src/main/java/dev/pinkroom/marketsight/ui/core/util/ReachedBottom.kt rename to app/src/main/java/dev/pinkroom/marketsight/presentation/core/util/ReachedBottom.kt index c7305f1..d2df462 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/core/util/ReachedBottom.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/presentation/core/util/ReachedBottom.kt @@ -1,4 +1,4 @@ -package dev.pinkroom.marketsight.ui.core.util +package dev.pinkroom.marketsight.presentation.core.util import androidx.compose.foundation.lazy.LazyListState diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/core/util/SelectableDatesImp.kt b/app/src/main/java/dev/pinkroom/marketsight/presentation/core/util/SelectableDatesImp.kt similarity index 95% rename from app/src/main/java/dev/pinkroom/marketsight/ui/core/util/SelectableDatesImp.kt rename to app/src/main/java/dev/pinkroom/marketsight/presentation/core/util/SelectableDatesImp.kt index 83ec5c3..41ed8be 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/core/util/SelectableDatesImp.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/presentation/core/util/SelectableDatesImp.kt @@ -1,4 +1,4 @@ -package dev.pinkroom.marketsight.ui.core.util +package dev.pinkroom.marketsight.presentation.core.util import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.SelectableDates diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/detail_screen/DetailScreen.kt b/app/src/main/java/dev/pinkroom/marketsight/presentation/detail_screen/DetailScreen.kt similarity index 91% rename from app/src/main/java/dev/pinkroom/marketsight/ui/detail_screen/DetailScreen.kt rename to app/src/main/java/dev/pinkroom/marketsight/presentation/detail_screen/DetailScreen.kt index 4ff4581..07cfbc5 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/detail_screen/DetailScreen.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/presentation/detail_screen/DetailScreen.kt @@ -1,4 +1,4 @@ -package dev.pinkroom.marketsight.ui.detail_screen +package dev.pinkroom.marketsight.presentation.detail_screen import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/home_screen/HomeScreen.kt b/app/src/main/java/dev/pinkroom/marketsight/presentation/home_screen/HomeScreen.kt similarity index 91% rename from app/src/main/java/dev/pinkroom/marketsight/ui/home_screen/HomeScreen.kt rename to app/src/main/java/dev/pinkroom/marketsight/presentation/home_screen/HomeScreen.kt index 87058cf..81b69f3 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/home_screen/HomeScreen.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/presentation/home_screen/HomeScreen.kt @@ -1,4 +1,4 @@ -package dev.pinkroom.marketsight.ui.home_screen +package dev.pinkroom.marketsight.presentation.home_screen import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/home_screen/HomeViewModel.kt b/app/src/main/java/dev/pinkroom/marketsight/presentation/home_screen/HomeViewModel.kt similarity index 76% rename from app/src/main/java/dev/pinkroom/marketsight/ui/home_screen/HomeViewModel.kt rename to app/src/main/java/dev/pinkroom/marketsight/presentation/home_screen/HomeViewModel.kt index 5421ff5..abad681 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/home_screen/HomeViewModel.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/presentation/home_screen/HomeViewModel.kt @@ -1,4 +1,4 @@ -package dev.pinkroom.marketsight.ui.home_screen +package dev.pinkroom.marketsight.presentation.home_screen import androidx.lifecycle.ViewModel import dagger.hilt.android.lifecycle.HiltViewModel diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsAction.kt b/app/src/main/java/dev/pinkroom/marketsight/presentation/news_screen/NewsAction.kt similarity index 84% rename from app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsAction.kt rename to app/src/main/java/dev/pinkroom/marketsight/presentation/news_screen/NewsAction.kt index 05fb0be..2070c92 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsAction.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/presentation/news_screen/NewsAction.kt @@ -1,4 +1,4 @@ -package dev.pinkroom.marketsight.ui.news_screen +package dev.pinkroom.marketsight.presentation.news_screen import androidx.annotation.StringRes import androidx.compose.material3.SnackbarDuration diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsEvent.kt b/app/src/main/java/dev/pinkroom/marketsight/presentation/news_screen/NewsEvent.kt similarity index 93% rename from app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsEvent.kt rename to app/src/main/java/dev/pinkroom/marketsight/presentation/news_screen/NewsEvent.kt index e43aa23..216b378 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsEvent.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/presentation/news_screen/NewsEvent.kt @@ -1,4 +1,4 @@ -package dev.pinkroom.marketsight.ui.news_screen +package dev.pinkroom.marketsight.presentation.news_screen import dev.pinkroom.marketsight.common.DateMomentType import dev.pinkroom.marketsight.common.SortType diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsScreen.kt b/app/src/main/java/dev/pinkroom/marketsight/presentation/news_screen/NewsScreen.kt similarity index 94% rename from app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsScreen.kt rename to app/src/main/java/dev/pinkroom/marketsight/presentation/news_screen/NewsScreen.kt index 3ea08bf..5318115 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsScreen.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/presentation/news_screen/NewsScreen.kt @@ -1,4 +1,4 @@ -package dev.pinkroom.marketsight.ui.news_screen +package dev.pinkroom.marketsight.presentation.news_screen import android.content.Context import android.content.Intent @@ -29,15 +29,15 @@ import dev.pinkroom.marketsight.domain.model.news.ImageSize import dev.pinkroom.marketsight.domain.model.news.ImagesNews import dev.pinkroom.marketsight.domain.model.news.NewsFilters import dev.pinkroom.marketsight.domain.model.news.NewsInfo -import dev.pinkroom.marketsight.ui.core.components.PullToRefreshLazyColumn -import dev.pinkroom.marketsight.ui.core.theme.dimens -import dev.pinkroom.marketsight.ui.core.util.reachedBottom -import dev.pinkroom.marketsight.ui.news_screen.components.AllNews -import dev.pinkroom.marketsight.ui.news_screen.components.BottomSheetFilters -import dev.pinkroom.marketsight.ui.news_screen.components.EmptyNewsList -import dev.pinkroom.marketsight.ui.news_screen.components.HeaderListNews -import dev.pinkroom.marketsight.ui.news_screen.components.MainNews -import dev.pinkroom.marketsight.ui.news_screen.components.RealTimeNews +import dev.pinkroom.marketsight.presentation.core.components.PullToRefreshLazyColumn +import dev.pinkroom.marketsight.presentation.core.theme.dimens +import dev.pinkroom.marketsight.presentation.core.util.reachedBottom +import dev.pinkroom.marketsight.presentation.news_screen.components.AllNews +import dev.pinkroom.marketsight.presentation.news_screen.components.BottomSheetFilters +import dev.pinkroom.marketsight.presentation.news_screen.components.EmptyNewsList +import dev.pinkroom.marketsight.presentation.news_screen.components.HeaderListNews +import dev.pinkroom.marketsight.presentation.news_screen.components.MainNews +import dev.pinkroom.marketsight.presentation.news_screen.components.RealTimeNews import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import java.time.LocalDateTime diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsUiState.kt b/app/src/main/java/dev/pinkroom/marketsight/presentation/news_screen/NewsUiState.kt similarity index 90% rename from app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsUiState.kt rename to app/src/main/java/dev/pinkroom/marketsight/presentation/news_screen/NewsUiState.kt index 0198c3c..10a0453 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsUiState.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/presentation/news_screen/NewsUiState.kt @@ -1,4 +1,4 @@ -package dev.pinkroom.marketsight.ui.news_screen +package dev.pinkroom.marketsight.presentation.news_screen import androidx.annotation.DrawableRes import dev.pinkroom.marketsight.domain.model.news.NewsFilters diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsViewModel.kt b/app/src/main/java/dev/pinkroom/marketsight/presentation/news_screen/NewsViewModel.kt similarity index 98% rename from app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsViewModel.kt rename to app/src/main/java/dev/pinkroom/marketsight/presentation/news_screen/NewsViewModel.kt index 1bd8b49..44f0127 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/NewsViewModel.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/presentation/news_screen/NewsViewModel.kt @@ -1,4 +1,4 @@ -package dev.pinkroom.marketsight.ui.news_screen +package dev.pinkroom.marketsight.presentation.news_screen import androidx.compose.material3.SnackbarDuration import androidx.lifecycle.ViewModel @@ -20,7 +20,7 @@ import dev.pinkroom.marketsight.domain.model.common.PaginationInfo import dev.pinkroom.marketsight.domain.model.common.SubInfoSymbols import dev.pinkroom.marketsight.domain.model.news.NewsFilters import dev.pinkroom.marketsight.domain.model.news.NewsResponse -import dev.pinkroom.marketsight.domain.use_case.news.ChangeFilterNews +import dev.pinkroom.marketsight.domain.use_case.news.ChangeFilterRealTimeNews import dev.pinkroom.marketsight.domain.use_case.news.GetNews import dev.pinkroom.marketsight.domain.use_case.news.GetRealTimeNews import kotlinx.coroutines.Job @@ -39,7 +39,7 @@ import javax.inject.Inject class NewsViewModel @Inject constructor( private val getRealTimeNews: GetRealTimeNews, private val getNews: GetNews, - private val changeFilterNews: ChangeFilterNews, + private val changeFilterRealTimeNews: ChangeFilterRealTimeNews, private val connectivityObserver: ConnectivityObserver, private val dispatchers: DispatcherProvider, ): ViewModel() { @@ -86,9 +86,9 @@ class NewsViewModel @Inject constructor( private var previousFilters: NewsFilters = uiState.value.filters init { - observeNetworkStatus() initNews() fetchRealTimeNews() + observeNetworkStatus() } fun onEvent(event: NewsEvent) { @@ -121,6 +121,7 @@ class NewsViewModel @Inject constructor( initNewsJob = viewModelScope.launch(dispatchers.IO) { _uiState.update { it.copy(isLoading = true) } paginationInfo = paginationInfo.copy(endReached = false) + val filters = uiState.value.filters val response = getNews( sortType = filters.sortBy, @@ -241,16 +242,16 @@ class NewsViewModel @Inject constructor( private fun revertFilters() = _uiState.update { it.copy(filters = previousFilters, isToShowFilters = false) } - private fun updateNewsWithNewFilters(newFilters: NewsFilters){ + private fun updateNewsWithNewFilters(newFilters: NewsFilters) { initNews() if (previousFilters.symbols != newFilters.symbols) updateFiltersRealTimeNews(newFilters) previousFilters = newFilters } - private fun updateFiltersRealTimeNews(newFilters: NewsFilters){ + private fun updateFiltersRealTimeNews(newFilters: NewsFilters) { viewModelScope.launch(dispatchers.IO) { _uiState.update { it.copy(realTimeNews = emptyList()) } - changeFilterNews( + changeFilterRealTimeNews( subscribeSymbols = newFilters.symbols.filter { it.isSubscribed }.map { it.symbol }, unsubscribeSymbols = newFilters.symbols.filter { !it.isSubscribed }.map { it.symbol } ).collect{ response -> diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/AllNews.kt b/app/src/main/java/dev/pinkroom/marketsight/presentation/news_screen/components/AllNews.kt similarity index 93% rename from app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/AllNews.kt rename to app/src/main/java/dev/pinkroom/marketsight/presentation/news_screen/components/AllNews.kt index a83a464..edc0b16 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/AllNews.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/presentation/news_screen/components/AllNews.kt @@ -1,4 +1,4 @@ -package dev.pinkroom.marketsight.ui.news_screen.components +package dev.pinkroom.marketsight.presentation.news_screen.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -20,8 +20,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import dev.pinkroom.marketsight.R import dev.pinkroom.marketsight.domain.model.news.NewsInfo -import dev.pinkroom.marketsight.ui.core.theme.dimens -import dev.pinkroom.marketsight.ui.core.theme.shimmerEffect +import dev.pinkroom.marketsight.presentation.core.theme.dimens +import dev.pinkroom.marketsight.presentation.core.theme.shimmerEffect fun LazyListScope.AllNews( modifier: Modifier = Modifier, diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/AllNewsCard.kt b/app/src/main/java/dev/pinkroom/marketsight/presentation/news_screen/components/AllNewsCard.kt similarity index 97% rename from app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/AllNewsCard.kt rename to app/src/main/java/dev/pinkroom/marketsight/presentation/news_screen/components/AllNewsCard.kt index 015a721..5ec0d5b 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/AllNewsCard.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/presentation/news_screen/components/AllNewsCard.kt @@ -1,4 +1,4 @@ -package dev.pinkroom.marketsight.ui.news_screen.components +package dev.pinkroom.marketsight.presentation.news_screen.components import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -22,7 +22,7 @@ import androidx.compose.ui.tooling.preview.Preview import dev.pinkroom.marketsight.domain.model.news.ImageSize import dev.pinkroom.marketsight.domain.model.news.ImagesNews import dev.pinkroom.marketsight.domain.model.news.NewsInfo -import dev.pinkroom.marketsight.ui.core.theme.dimens +import dev.pinkroom.marketsight.presentation.core.theme.dimens import java.time.LocalDateTime @Composable diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/BottomSheetFilters.kt b/app/src/main/java/dev/pinkroom/marketsight/presentation/news_screen/components/BottomSheetFilters.kt similarity index 95% rename from app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/BottomSheetFilters.kt rename to app/src/main/java/dev/pinkroom/marketsight/presentation/news_screen/components/BottomSheetFilters.kt index 0d218d7..08c7403 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/BottomSheetFilters.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/presentation/news_screen/components/BottomSheetFilters.kt @@ -1,4 +1,4 @@ -package dev.pinkroom.marketsight.ui.news_screen.components +package dev.pinkroom.marketsight.presentation.news_screen.components import androidx.compose.animation.AnimatedVisibility import androidx.compose.foundation.ExperimentalFoundationApi @@ -34,12 +34,12 @@ import dev.pinkroom.marketsight.common.DateMomentType import dev.pinkroom.marketsight.common.SortType import dev.pinkroom.marketsight.common.toReadableDate import dev.pinkroom.marketsight.domain.model.common.SubInfoSymbols -import dev.pinkroom.marketsight.ui.core.components.ButtonFilter -import dev.pinkroom.marketsight.ui.core.components.DatePickerComponent -import dev.pinkroom.marketsight.ui.core.components.DefaultSectionFilter -import dev.pinkroom.marketsight.ui.core.theme.Blue -import dev.pinkroom.marketsight.ui.core.theme.dimens -import dev.pinkroom.marketsight.ui.core.util.SelectableDatesImp +import dev.pinkroom.marketsight.presentation.core.components.ButtonFilter +import dev.pinkroom.marketsight.presentation.core.components.DatePickerComponent +import dev.pinkroom.marketsight.presentation.core.components.DefaultSectionFilter +import dev.pinkroom.marketsight.presentation.core.theme.Blue +import dev.pinkroom.marketsight.presentation.core.theme.dimens +import dev.pinkroom.marketsight.presentation.core.util.SelectableDatesImp import java.time.LocalDate @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/EmptyNewsList.kt b/app/src/main/java/dev/pinkroom/marketsight/presentation/news_screen/components/EmptyNewsList.kt similarity index 92% rename from app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/EmptyNewsList.kt rename to app/src/main/java/dev/pinkroom/marketsight/presentation/news_screen/components/EmptyNewsList.kt index 9910814..100c988 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/EmptyNewsList.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/presentation/news_screen/components/EmptyNewsList.kt @@ -1,4 +1,4 @@ -package dev.pinkroom.marketsight.ui.news_screen.components +package dev.pinkroom.marketsight.presentation.news_screen.components import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -16,7 +16,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import dev.pinkroom.marketsight.R -import dev.pinkroom.marketsight.ui.core.theme.dimens +import dev.pinkroom.marketsight.presentation.core.theme.dimens @Composable fun EmptyNewsList( diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/HeaderListNews.kt b/app/src/main/java/dev/pinkroom/marketsight/presentation/news_screen/components/HeaderListNews.kt similarity index 93% rename from app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/HeaderListNews.kt rename to app/src/main/java/dev/pinkroom/marketsight/presentation/news_screen/components/HeaderListNews.kt index abad84a..f24fde7 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/HeaderListNews.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/presentation/news_screen/components/HeaderListNews.kt @@ -1,4 +1,4 @@ -package dev.pinkroom.marketsight.ui.news_screen.components +package dev.pinkroom.marketsight.presentation.news_screen.components import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -15,7 +15,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import dev.pinkroom.marketsight.R -import dev.pinkroom.marketsight.ui.core.theme.dimens +import dev.pinkroom.marketsight.presentation.core.theme.dimens @Composable fun HeaderListNews( diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/ImageNews.kt b/app/src/main/java/dev/pinkroom/marketsight/presentation/news_screen/components/ImageNews.kt similarity index 88% rename from app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/ImageNews.kt rename to app/src/main/java/dev/pinkroom/marketsight/presentation/news_screen/components/ImageNews.kt index 7e86464..1bc3b1b 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/ImageNews.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/presentation/news_screen/components/ImageNews.kt @@ -1,4 +1,4 @@ -package dev.pinkroom.marketsight.ui.news_screen.components +package dev.pinkroom.marketsight.presentation.news_screen.components import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Box @@ -9,7 +9,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import coil.compose.SubcomposeAsyncImage import dev.pinkroom.marketsight.R -import dev.pinkroom.marketsight.ui.core.theme.shimmerEffect +import dev.pinkroom.marketsight.presentation.core.theme.shimmerEffect @Composable fun ImageNews( diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/IndicatorPageCarousel.kt b/app/src/main/java/dev/pinkroom/marketsight/presentation/news_screen/components/IndicatorPageCarousel.kt similarity index 91% rename from app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/IndicatorPageCarousel.kt rename to app/src/main/java/dev/pinkroom/marketsight/presentation/news_screen/components/IndicatorPageCarousel.kt index 25c9640..2fcb23e 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/IndicatorPageCarousel.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/presentation/news_screen/components/IndicatorPageCarousel.kt @@ -1,4 +1,4 @@ -package dev.pinkroom.marketsight.ui.news_screen.components +package dev.pinkroom.marketsight.presentation.news_screen.components import androidx.compose.animation.core.tween import androidx.compose.foundation.ExperimentalFoundationApi @@ -20,9 +20,9 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import dev.pinkroom.marketsight.common.Constants -import dev.pinkroom.marketsight.ui.core.theme.PhilippineGray -import dev.pinkroom.marketsight.ui.core.theme.PhilippineSilver -import dev.pinkroom.marketsight.ui.core.theme.dimens +import dev.pinkroom.marketsight.presentation.core.theme.PhilippineGray +import dev.pinkroom.marketsight.presentation.core.theme.PhilippineSilver +import dev.pinkroom.marketsight.presentation.core.theme.dimens import kotlinx.coroutines.launch @OptIn(ExperimentalFoundationApi::class) diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/MainNews.kt b/app/src/main/java/dev/pinkroom/marketsight/presentation/news_screen/components/MainNews.kt similarity index 95% rename from app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/MainNews.kt rename to app/src/main/java/dev/pinkroom/marketsight/presentation/news_screen/components/MainNews.kt index 01953ab..86f3dc1 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/MainNews.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/presentation/news_screen/components/MainNews.kt @@ -1,4 +1,4 @@ -package dev.pinkroom.marketsight.ui.news_screen.components +package dev.pinkroom.marketsight.presentation.news_screen.components import androidx.compose.animation.core.tween import androidx.compose.foundation.ExperimentalFoundationApi @@ -28,8 +28,8 @@ import androidx.compose.ui.util.lerp import dev.pinkroom.marketsight.common.Constants import dev.pinkroom.marketsight.domain.model.news.NewsInfo import dev.pinkroom.marketsight.domain.model.news.getAspectRatio -import dev.pinkroom.marketsight.ui.core.theme.dimens -import dev.pinkroom.marketsight.ui.core.theme.shimmerEffect +import dev.pinkroom.marketsight.presentation.core.theme.dimens +import dev.pinkroom.marketsight.presentation.core.theme.shimmerEffect import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlin.math.absoluteValue diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/MainNewsCard.kt b/app/src/main/java/dev/pinkroom/marketsight/presentation/news_screen/components/MainNewsCard.kt similarity index 95% rename from app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/MainNewsCard.kt rename to app/src/main/java/dev/pinkroom/marketsight/presentation/news_screen/components/MainNewsCard.kt index ceed469..a29bcc2 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/MainNewsCard.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/presentation/news_screen/components/MainNewsCard.kt @@ -1,4 +1,4 @@ -package dev.pinkroom.marketsight.ui.news_screen.components +package dev.pinkroom.marketsight.presentation.news_screen.components import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -33,9 +33,9 @@ import dev.pinkroom.marketsight.domain.model.news.ImageSize import dev.pinkroom.marketsight.domain.model.news.ImagesNews import dev.pinkroom.marketsight.domain.model.news.NewsInfo import dev.pinkroom.marketsight.domain.model.news.getAspectRatio -import dev.pinkroom.marketsight.ui.core.theme.Black -import dev.pinkroom.marketsight.ui.core.theme.White -import dev.pinkroom.marketsight.ui.core.theme.dimens +import dev.pinkroom.marketsight.presentation.core.theme.Black +import dev.pinkroom.marketsight.presentation.core.theme.White +import dev.pinkroom.marketsight.presentation.core.theme.dimens import java.time.LocalDateTime @Composable diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/RealTimeNews.kt b/app/src/main/java/dev/pinkroom/marketsight/presentation/news_screen/components/RealTimeNews.kt similarity index 95% rename from app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/RealTimeNews.kt rename to app/src/main/java/dev/pinkroom/marketsight/presentation/news_screen/components/RealTimeNews.kt index f583189..495b73f 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/RealTimeNews.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/presentation/news_screen/components/RealTimeNews.kt @@ -1,4 +1,4 @@ -package dev.pinkroom.marketsight.ui.news_screen.components +package dev.pinkroom.marketsight.presentation.news_screen.components import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.Spring @@ -35,8 +35,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import dev.pinkroom.marketsight.R import dev.pinkroom.marketsight.domain.model.news.NewsInfo -import dev.pinkroom.marketsight.ui.core.theme.Red -import dev.pinkroom.marketsight.ui.core.theme.dimens +import dev.pinkroom.marketsight.presentation.core.theme.Red +import dev.pinkroom.marketsight.presentation.core.theme.dimens @Composable fun RealTimeNews( diff --git a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/RealTimeNewsCard.kt b/app/src/main/java/dev/pinkroom/marketsight/presentation/news_screen/components/RealTimeNewsCard.kt similarity index 96% rename from app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/RealTimeNewsCard.kt rename to app/src/main/java/dev/pinkroom/marketsight/presentation/news_screen/components/RealTimeNewsCard.kt index 99b87e0..49e11e2 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/ui/news_screen/components/RealTimeNewsCard.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/presentation/news_screen/components/RealTimeNewsCard.kt @@ -1,4 +1,4 @@ -package dev.pinkroom.marketsight.ui.news_screen.components +package dev.pinkroom.marketsight.presentation.news_screen.components import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -23,7 +23,7 @@ import androidx.compose.ui.tooling.preview.Preview import dev.pinkroom.marketsight.domain.model.news.ImageSize import dev.pinkroom.marketsight.domain.model.news.ImagesNews import dev.pinkroom.marketsight.domain.model.news.NewsInfo -import dev.pinkroom.marketsight.ui.core.theme.dimens +import dev.pinkroom.marketsight.presentation.core.theme.dimens import java.time.LocalDateTime @Composable diff --git a/app/src/test/java/dev/pinkroom/marketsight/data/data_source/NewsRemoteDataSourceTest.kt b/app/src/test/java/dev/pinkroom/marketsight/data/data_source/NewsRemoteDataSourceTest.kt index 4ff5fd8..eefb259 100644 --- a/app/src/test/java/dev/pinkroom/marketsight/data/data_source/NewsRemoteDataSourceTest.kt +++ b/app/src/test/java/dev/pinkroom/marketsight/data/data_source/NewsRemoteDataSourceTest.kt @@ -59,17 +59,23 @@ class NewsRemoteDataSourceTest { ) @Test - fun `Given params, when init subscribe news with all topics, then sendMessage is called with correct params`() = runTest { + fun `When call on getRealTimeNews twice, then in just in 1 time need to subscribe symbols`() = runTest { // GIVEN - mockStartAlpacaServiceWithSuccess() - val symbols = listOf("*") + val listNews = newsFactory.buildList() + mockNewsServiceWithOpenConnection() + mockNewsServiceWithSuccess(newsList = listNews) // WHEN - alpacaRemoteDataSource.subscribeNews(symbols = symbols).firstOrNull() + var response = alpacaRemoteDataSource.getRealTimeNews().first() + assertThat(response).isNotEmpty() + response = alpacaRemoteDataSource.getRealTimeNews().first() + assertThat(response).isNotEmpty() // THEN - val expectedMessageArgs = MessageAlpacaService(action = ActionAlpaca.Subscribe.action, news = symbols) - verify { alpacaNewsService.sendMessage(message = expectedMessageArgs) } + val expectedMessageToSubscribe = MessageAlpacaService( + action = ActionAlpaca.Subscribe.action, news = listOf("*"), + ) + verify(exactly = 1) { alpacaNewsService.sendMessage(expectedMessageToSubscribe) } } @Test @@ -181,6 +187,12 @@ class NewsRemoteDataSourceTest { ) } + private fun mockNewsServiceWithOpenConnection(){ + every { alpacaNewsService.observeOnConnectionEvent() }.returns( + flow { emit(WebSocket.Event.OnConnectionOpened(webSocket = Any())) } + ) + } + private fun mockNewsServiceWithSuccess(newsList: List) { every { alpacaNewsService.observeResponse() }.returns( flow { @@ -208,16 +220,6 @@ class NewsRemoteDataSourceTest { ) } - private fun mockStartAlpacaServiceWithSuccess() { - every { alpacaNewsService.sendMessage(any()) }.returns(Unit) - - every { alpacaNewsService.observeOnConnectionEvent() }.returns( - flow { - emit(WebSocket.Event.OnConnectionOpened(webSocket = Any())) - } - ) - } - private fun mockMessageSubscriptionServiceWithSuccess(messageAlpacaService: MessageAlpacaService) { val returnedMessageService = SubscriptionMessageDto( type = "subscription", diff --git a/app/src/test/java/dev/pinkroom/marketsight/data/repository/NewsRepositoryTest.kt b/app/src/test/java/dev/pinkroom/marketsight/data/repository/NewsRepositoryTest.kt index 7f743ad..4032aaf 100644 --- a/app/src/test/java/dev/pinkroom/marketsight/data/repository/NewsRepositoryTest.kt +++ b/app/src/test/java/dev/pinkroom/marketsight/data/repository/NewsRepositoryTest.kt @@ -5,7 +5,6 @@ import assertk.assertions.isEqualTo import assertk.assertions.isNotEmpty import assertk.assertions.isTrue import com.github.javafaker.Faker -import com.tinder.scarlet.WebSocket import dev.pinkroom.marketsight.common.ActionAlpaca import dev.pinkroom.marketsight.common.Resource import dev.pinkroom.marketsight.common.SortType @@ -47,18 +46,6 @@ class NewsRepositoryTest{ dispatchers = dispatchers, ) - @Test - fun `Given params, when init subscribe news, then subscribeNews is called with correct params and emit values`() = runTest { - // GIVEN - mockSubscribeNewsWithSuccess() - - // WHEN - val response = newsRepository.subscribeNews().first() - - // THEN - assertThat(response is Resource.Success).isTrue() - } - @Test fun `When receive message on getRealTimeNews, then emit NewsInfo class`() = runTest { // GIVEN @@ -138,7 +125,7 @@ class NewsRepositoryTest{ mockMessageSubscriptionServiceWithSuccess(messageToSend) // WHEN - val response = newsRepository.changeFilterNews(symbols = messageToSend.news!!, actionAlpaca = ActionAlpaca.Subscribe) + val response = newsRepository.changeFilterRealTimeNews(symbols = messageToSend.news!!, actionAlpaca = ActionAlpaca.Subscribe) // THEN verify { newsRemoteDataSource.sendSubscribeMessageToAlpacaService(message = messageToSend) } @@ -154,7 +141,7 @@ class NewsRepositoryTest{ mockMessageSubscriptionServiceWithError() // WHEN - val response = newsRepository.changeFilterNews(symbols = messageToSend.news!!, actionAlpaca = ActionAlpaca.Subscribe) + val response = newsRepository.changeFilterRealTimeNews(symbols = messageToSend.news!!, actionAlpaca = ActionAlpaca.Subscribe) // THEN verify { newsRemoteDataSource.sendSubscribeMessageToAlpacaService(message = messageToSend) } @@ -201,14 +188,6 @@ class NewsRepositoryTest{ ) } - private fun mockSubscribeNewsWithSuccess(){ - every { newsRemoteDataSource.subscribeNews(any()) }.returns( - flow { - emit(Resource.Success(data = WebSocket.Event.OnConnectionOpened(webSocket = Any()))) - } - ) - } - private fun mockMessageSubscriptionServiceWithSuccess(messageAlpacaService: MessageAlpacaService) { val returnedMessageService = SubscriptionMessageDto( type = "subscription", diff --git a/app/src/test/java/dev/pinkroom/marketsight/domain/use_case/news/ChangeFilterNewsTest.kt b/app/src/test/java/dev/pinkroom/marketsight/domain/use_case/news/ChangeFilterRealTimeNewsTest.kt similarity index 86% rename from app/src/test/java/dev/pinkroom/marketsight/domain/use_case/news/ChangeFilterNewsTest.kt rename to app/src/test/java/dev/pinkroom/marketsight/domain/use_case/news/ChangeFilterRealTimeNewsTest.kt index 0ab95d6..19d1fd9 100644 --- a/app/src/test/java/dev/pinkroom/marketsight/domain/use_case/news/ChangeFilterNewsTest.kt +++ b/app/src/test/java/dev/pinkroom/marketsight/domain/use_case/news/ChangeFilterRealTimeNewsTest.kt @@ -20,14 +20,14 @@ import org.junit.Rule import org.junit.Test @ExperimentalCoroutinesApi -class ChangeFilterNewsTest{ +class ChangeFilterRealTimeNewsTest{ @get:Rule val coroutineRule = MainCoroutineRule() private val dispatchers = TestDispatcherProvider() private val newsRepository = mockk(relaxed = true, relaxUnitFun = true) - private val changeFilterNews = ChangeFilterNews( + private val changeFilterRealTimeNews = ChangeFilterRealTimeNews( newsRepository = newsRepository, dispatchers = dispatchers, ) @@ -40,20 +40,20 @@ class ChangeFilterNewsTest{ mockChangeFilterNewsSuccess(subscribeSymbols) // WHEN - val response = changeFilterNews( + val response = changeFilterRealTimeNews( subscribeSymbols = subscribeSymbols, unsubscribeSymbols = unsubscribeSymbols ).toList() // THEN coVerify { - newsRepository.changeFilterNews( + newsRepository.changeFilterRealTimeNews( symbols = unsubscribeSymbols, actionAlpaca = ActionAlpaca.Unsubscribe, ) } coVerify { - newsRepository.changeFilterNews( + newsRepository.changeFilterRealTimeNews( symbols = subscribeSymbols, actionAlpaca = ActionAlpaca.Subscribe, ) @@ -79,20 +79,20 @@ class ChangeFilterNewsTest{ ) // WHEN - val response = changeFilterNews( + val response = changeFilterRealTimeNews( subscribeSymbols = subscribeSymbols, unsubscribeSymbols = unsubscribeSymbols ).last() // THEN coVerify { - newsRepository.changeFilterNews( + newsRepository.changeFilterRealTimeNews( symbols = unsubscribeSymbols, actionAlpaca = ActionAlpaca.Unsubscribe, ) } coVerify { - newsRepository.changeFilterNews( + newsRepository.changeFilterRealTimeNews( symbols = subscribeSymbols, actionAlpaca = ActionAlpaca.Subscribe, ) @@ -106,7 +106,7 @@ class ChangeFilterNewsTest{ private fun mockChangeFilterNewsSuccess( subscribedSymbols: List, ){ - coEvery { newsRepository.changeFilterNews(any(),any()) }.returnsMany( + coEvery { newsRepository.changeFilterRealTimeNews(any(),any()) }.returnsMany( Resource.Success(data = emptyList()), Resource.Success(data = subscribedSymbols) ) } @@ -115,7 +115,7 @@ class ChangeFilterNewsTest{ subscribedSymbols: List, unsubscribedSymbols: List, ){ - coEvery { newsRepository.changeFilterNews(any(),any()) }.returns( + coEvery { newsRepository.changeFilterRealTimeNews(any(),any()) }.returns( Resource.Error(data = subscribedSymbols + unsubscribedSymbols) ) } diff --git a/app/src/test/java/dev/pinkroom/marketsight/presentation/news_screen/NewsViewModelTest.kt b/app/src/test/java/dev/pinkroom/marketsight/presentation/news_screen/NewsViewModelTest.kt new file mode 100644 index 0000000..69816b2 --- /dev/null +++ b/app/src/test/java/dev/pinkroom/marketsight/presentation/news_screen/NewsViewModelTest.kt @@ -0,0 +1,678 @@ +package dev.pinkroom.marketsight.presentation.news_screen + +import app.cash.turbine.test +import assertk.assertThat +import assertk.assertions.isEmpty +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isGreaterThan +import assertk.assertions.isInstanceOf +import assertk.assertions.isNotEmpty +import assertk.assertions.isNotEqualTo +import assertk.assertions.isNotNull +import assertk.assertions.isTrue +import dev.pinkroom.marketsight.common.Constants.ALL_SYMBOLS +import dev.pinkroom.marketsight.common.Constants.LIMIT_NEWS +import dev.pinkroom.marketsight.common.Constants.MAX_ITEMS_CAROUSEL +import dev.pinkroom.marketsight.common.DateMomentType +import dev.pinkroom.marketsight.common.Resource +import dev.pinkroom.marketsight.common.SortType +import dev.pinkroom.marketsight.common.atEndOfTheDay +import dev.pinkroom.marketsight.common.connection_network.ConnectivityObserver +import dev.pinkroom.marketsight.data.mapper.toNewsInfo +import dev.pinkroom.marketsight.domain.model.news.NewsFilters +import dev.pinkroom.marketsight.domain.model.news.NewsInfo +import dev.pinkroom.marketsight.domain.model.news.NewsResponse +import dev.pinkroom.marketsight.domain.use_case.news.ChangeFilterRealTimeNews +import dev.pinkroom.marketsight.domain.use_case.news.GetNews +import dev.pinkroom.marketsight.domain.use_case.news.GetRealTimeNews +import dev.pinkroom.marketsight.factories.NewsDtoFactory +import dev.pinkroom.marketsight.util.MainCoroutineRule +import dev.pinkroom.marketsight.util.TestDispatcherProvider +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import java.time.Instant +import java.time.ZoneOffset + +@ExperimentalCoroutinesApi +class NewsViewModelTest{ + + @get:Rule + val coroutineRule = MainCoroutineRule() + + private val dispatchers = TestDispatcherProvider() + private val getNews = mockk(relaxed = true, relaxUnitFun = true) + private val getRealTimeNews = mockk(relaxed = true, relaxUnitFun = true) + private val changeFilterRealTimeNews = mockk(relaxed = true, relaxUnitFun = true) + private val connectivityObserver = mockk(relaxed = true, relaxUnitFun = true) + private lateinit var newsViewModel: NewsViewModel + + private fun initViewModel() { + newsViewModel = NewsViewModel( + getNews = getNews, + getRealTimeNews = getRealTimeNews, + changeFilterRealTimeNews = changeFilterRealTimeNews, + connectivityObserver = connectivityObserver, + dispatchers = dispatchers, + ) + } + + @Test + fun `Given access to Net, When init VM, Then Success on Get News and RealTime News`() = runTest { + // GIVEN + val news = NewsDtoFactory().buildList(number = LIMIT_NEWS).map { it.toNewsInfo() } + mockResponseGetNewsSuccess(news) + mockGetRealTimeNews(news) + + // WHEN + initViewModel() + advanceUntilIdle() + + // THEN + val uiState = newsViewModel.uiState.value + val filters = uiState.filters + + coVerify { + getNews.invoke( + pageToken = any(), limitPerPage = any(), sortType = filters.sortBy, + symbols = filters.getSubscribedSymbols(), + startDate = filters.startDateSort?.atStartOfDay(), + endDate = filters.endDateSort?.atEndOfTheDay(), + ) + } + assertThat(uiState.news).isNotEmpty() + assertThat(uiState.mainNews).isNotEmpty() + assertThat(uiState.isLoading).isFalse() + assertThat(uiState.mainNews).isEqualTo(news.take(MAX_ITEMS_CAROUSEL)) + assertThat(uiState.news).isEqualTo(news.drop(MAX_ITEMS_CAROUSEL)) + + coVerify { getRealTimeNews.invoke() } + assertThat(uiState.realTimeNews).isNotEmpty() + } + + @Test + fun `Start Without Net, When init VM, Then Error on Get News`() = runTest { + // GIVEN + mockResponseGetNewsError() + + // WHEN + initViewModel() + advanceUntilIdle() + + // THEN + val uiState = newsViewModel.uiState.value + + coVerify { + getNews.invoke( + pageToken = any(), limitPerPage = any(), sortType = any(), + symbols = any(), startDate = any(), endDate = any(), + ) + } + assertThat(uiState.news).isEmpty() + assertThat(uiState.mainNews).isEmpty() + assertThat(uiState.isLoading).isFalse() + assertThat(uiState.mainNews).isEmpty() + assertThat(uiState.news).isEmpty() + assertThat(uiState.errorMessage).isNotNull() + } + + @Test + fun `Given without Net, When init VM, Then it gets net and automatically calls GetNews`() = runTest { + // GIVEN + val news = NewsDtoFactory().buildList(number = LIMIT_NEWS).map { it.toNewsInfo() } + mockNetwork() + mockResponseGetNewsErrorThenSuccess(news) + + // WHEN + initViewModel() + + // THEN + newsViewModel.uiState.test { + var item = awaitItem() + assertThat(item.isLoading).isTrue() + item = awaitItem() + assertThat(item.isLoading).isFalse() + assertThat(item.news).isEmpty() + assertThat(item.errorMessage).isNotNull() + } + advanceUntilIdle() + val uiState = newsViewModel.uiState.value + + coVerify(exactly = 2) { + getNews.invoke() + } + assertThat(uiState.news).isNotEmpty() + assertThat(uiState.mainNews).isNotEmpty() + assertThat(uiState.isLoading).isFalse() + assertThat(uiState.mainNews).isEqualTo(news.take(MAX_ITEMS_CAROUSEL)) + assertThat(uiState.news).isEqualTo(news.drop(MAX_ITEMS_CAROUSEL)) + } + + @Test + fun `When RetryNews event, then init news`() = runTest { + // GIVEN + initViewModel() + val news = NewsDtoFactory().buildList(number = LIMIT_NEWS).map { it.toNewsInfo() } + mockResponseGetNewsSuccess(news) + + // WHEN + newsViewModel.onEvent(event = NewsEvent.RetryNews) + + // THEN + advanceUntilIdle() + val uiState = newsViewModel.uiState.value + + coVerify { + getNews.invoke( + pageToken = any(), limitPerPage = any(), sortType = any(), + symbols = any(), startDate = any(), endDate = any(), + ) + } + assertThat(uiState.news).isNotEmpty() + assertThat(uiState.mainNews).isNotEmpty() + assertThat(uiState.isLoading).isFalse() + assertThat(uiState.mainNews).isEqualTo(news.take(MAX_ITEMS_CAROUSEL)) + assertThat(uiState.news).isEqualTo(news.drop(MAX_ITEMS_CAROUSEL)) + } + + @Test + fun `When request for news is running, Then if the call is made again it is ignored`() = runTest { + // GIVEN + initViewModel() + + // WHEN + newsViewModel.onEvent(event = NewsEvent.RetryNews) + newsViewModel.onEvent(event = NewsEvent.RetryNews) + + // THEN + advanceUntilIdle() + + coVerify(exactly = 1) { + getNews.invoke( + pageToken = any(), limitPerPage = any(), sortType = any(), + symbols = any(), startDate = any(), endDate = any(), + ) + } + } + + @Test + fun `When RetryRealTimeNewsSubscribe event is called, then send request to service`() = runTest { + // GIVEN + initViewModel() + val filters = newsViewModel.uiState.value.filters + + // WHEN + newsViewModel.onEvent(event = NewsEvent.RetryRealTimeNewsSubscribe) + + // THEN + advanceUntilIdle() + + coVerify { + changeFilterRealTimeNews.invoke( + subscribeSymbols = filters.symbols.filter { it.isSubscribed }.map { it.symbol }, + unsubscribeSymbols = filters.symbols.filter { !it.isSubscribed }.map { it.symbol }, + ) + } + } + + @Test + fun `When RetryRealTimeNewsSubscribe event is called, then receive Error from service and send Action to show SnackBar`() = runTest { + // GIVEN + initViewModel() + val filters = newsViewModel.uiState.value.filters + mockChangeFilterRealTimeNewsError() + + // WHEN + newsViewModel.onEvent(event = NewsEvent.RetryRealTimeNewsSubscribe) + + // THEN + advanceUntilIdle() + + coVerify { + changeFilterRealTimeNews.invoke( + subscribeSymbols = filters.symbols.filter { it.isSubscribed }.map { it.symbol }, + unsubscribeSymbols = filters.symbols.filter { !it.isSubscribed }.map { it.symbol }, + ) + } + val action = newsViewModel.action.first() + assertThat(action).isInstanceOf(NewsAction.ShowSnackBar::class.java) + } + + @Test + fun `When RefreshNews event is called, then call init news`() = runTest { + // GIVEN + initViewModel() + advanceUntilIdle() + + // WHEN + newsViewModel.onEvent(event = NewsEvent.RefreshNews) + + // THEN + newsViewModel.uiState.test { + val uiState = awaitItem() + assertThat(uiState.isRefreshing).isTrue() + } + + coVerify { + getNews.invoke( + pageToken = any(), limitPerPage = any(), sortType = any(), + symbols = any(), startDate = any(), endDate = any(), + ) + } + } + + @Test + fun `When LoadMoreNews event is called, then get news`() = runTest { + // GIVEN + val news = NewsDtoFactory().buildList(number = LIMIT_NEWS).map { it.toNewsInfo() } + val nextToken = "RandomStringToken" + mockResponseGetNewsSuccess(news = news, nextPageToken = nextToken) + initViewModel() + advanceUntilIdle() + + // WHEN + newsViewModel.onEvent(event = NewsEvent.LoadMoreNews) + advanceUntilIdle() + + // THEN + val uiState = newsViewModel.uiState.value + + coVerify(exactly = 2) { + getNews.invoke( + pageToken = any(), limitPerPage = any(), sortType = any(), + symbols = any(), startDate = any(), endDate = any(), + ) + } + assertThat(uiState.news.size).isGreaterThan(news.size) + } + + @Test + fun `When LoadMoreNews event is called twice, then just one call to get news`() = runTest { + // GIVEN + val news = NewsDtoFactory().buildList(number = LIMIT_NEWS).map { it.toNewsInfo() } + val nextToken = "RandomStringToken" + mockResponseGetNewsSuccess(news = news, nextPageToken = nextToken) + initViewModel() + advanceUntilIdle() + + // WHEN + newsViewModel.onEvent(event = NewsEvent.LoadMoreNews) + newsViewModel.onEvent(event = NewsEvent.LoadMoreNews) + advanceUntilIdle() + + // THEN + coVerify(exactly = 2) { // IS 2 BECAUSE ON INIT VIEWMODEL IS CALLED GET NEWS + getNews.invoke( + pageToken = any(), limitPerPage = any(), sortType = any(), + symbols = any(), startDate = any(), endDate = any(), + ) + } + } + + @Test + fun `When LoadMoreNews event is called but end is reached, then just ignore`() = runTest { + // GIVEN + val news = NewsDtoFactory().buildList(number = LIMIT_NEWS).map { it.toNewsInfo() } + mockResponseGetNewsSuccess(news = news, nextPageToken = null) + initViewModel() + advanceUntilIdle() + + // WHEN + newsViewModel.onEvent(event = NewsEvent.LoadMoreNews) + + // THEN + coVerify(exactly = 1) { // IS 1 BECAUSE ON INIT VIEWMODEL IS CALLED GET NEWS + getNews.invoke( + pageToken = any(), limitPerPage = any(), sortType = any(), + symbols = any(), startDate = any(), endDate = any(), + ) + } + } + + @Test + fun `When ShowOrHideFilters event is called, Then change isToShowFilters`() = runTest { + // GIVEN + initViewModel() + advanceUntilIdle() + val isToShow = true + + // WHEN + newsViewModel.onEvent(event = NewsEvent.ShowOrHideFilters(isToShow = isToShow)) + + // THEN + val uiState = newsViewModel.uiState.value + assertThat(uiState.isToShowFilters).isEqualTo(isToShow) + } + + @Test + fun `When ShowOrHideFilters event is called but is loading, Then show filters is false`() = runTest { + // GIVEN + val news = NewsDtoFactory().buildList(number = LIMIT_NEWS).map { it.toNewsInfo() } + mockResponseGetNewsSuccess(news = news, nextPageToken = null) + val isToShow = true + + // WHEN + initViewModel() + newsViewModel.onEvent(event = NewsEvent.ShowOrHideFilters(isToShow = isToShow)) + advanceUntilIdle() + + // THEN + val uiState = newsViewModel.uiState.value + assertThat(uiState.isToShowFilters).isEqualTo(!isToShow) + } + + @Test + fun `When ChangeSort event is called, Then update filters in uiState`() = runTest { + // GIVEN + initViewModel() + val newSort = SortType.ASC + + // WHEN + newsViewModel.onEvent(event = NewsEvent.ChangeSort(sort = newSort)) + advanceUntilIdle() + + // THEN + val filters = newsViewModel.uiState.value.filters + assertThat(filters.sortBy).isEqualTo(newSort) + } + + @Test + fun `When ChangeSymbol event is called, Then update filters in uiState`() = runTest { + // GIVEN + initViewModel() + val allSymbols = newsViewModel.uiState.value.filters.symbols + val newSymbolToChange = allSymbols.last() + + // WHEN + newsViewModel.onEvent(event = NewsEvent.ChangeSymbol(symbolToChange = newSymbolToChange)) + advanceUntilIdle() + + // THEN + val symbols = newsViewModel.uiState.value.filters.symbols + val lastSymbol = symbols.last() + val all = symbols.find { it.name == ALL_SYMBOLS } + assertThat(lastSymbol.isSubscribed).isNotEqualTo(newSymbolToChange.isSubscribed) + assertThat(all!!.isSubscribed).isFalse() + } + + @Test + fun `When ChangeSymbol event is called to unsubscribe last subscribed symbol, Then all symbol is subscribed`() = runTest { + // GIVEN + initViewModel() + newsViewModel.onEvent(event = NewsEvent.ChangeSymbol(symbolToChange = newsViewModel.uiState.value.filters.symbols.last())) // SUBSCRIBE LAST + advanceUntilIdle() + + // WHEN + newsViewModel.onEvent(event = NewsEvent.ChangeSymbol(symbolToChange = newsViewModel.uiState.value.filters.symbols.last())) // UNSUBSCRIBE LAST + advanceUntilIdle() + + // THEN + val symbols = newsViewModel.uiState.value.filters.symbols + val lastSymbol = symbols.last() + val all = symbols.find { it.name == ALL_SYMBOLS } + assertThat(lastSymbol.isSubscribed).isFalse() + assertThat(all!!.isSubscribed).isTrue() + } + + @Test + fun `When ChangeSymbol event is called to subscribe ALL, Then all symbol is subscribed and other items are unsubscribed`() = runTest { + // GIVEN + initViewModel() + val symbolToChange = newsViewModel.uiState.value.filters.symbols.size - 1 + newsViewModel.onEvent(event = NewsEvent.ChangeSymbol(symbolToChange = newsViewModel.uiState.value.filters.symbols.elementAt(symbolToChange))) + newsViewModel.onEvent(event = NewsEvent.ChangeSymbol(symbolToChange = newsViewModel.uiState.value.filters.symbols.last())) + advanceUntilIdle() + + // WHEN + var symbols = newsViewModel.uiState.value.filters.symbols + var all = symbols.find { it.name == ALL_SYMBOLS } + newsViewModel.onEvent(event = NewsEvent.ChangeSymbol(symbolToChange = all!!)) // SUBSCRIBE ALL + advanceUntilIdle() + + // THEN + symbols = newsViewModel.uiState.value.filters.symbols + all = symbols.find { it.name == ALL_SYMBOLS } + assertThat(all!!.isSubscribed).isTrue() + assertThat(symbols.count { it.isSubscribed }).isEqualTo(1) + } + + @Test + fun `When ChangeDate event is called, Then update date`() = runTest { + // GIVEN + initViewModel() + val dateInMillis = 1714399712434L + + // WHEN + newsViewModel.onEvent(event = NewsEvent.ChangeDate(newDateInMillis = dateInMillis, dateMomentType = DateMomentType.Start)) + newsViewModel.onEvent(event = NewsEvent.ChangeDate(newDateInMillis = dateInMillis, dateMomentType = DateMomentType.End)) + advanceUntilIdle() + + // THEN + val expectedDate = Instant.ofEpochMilli(dateInMillis).atZone(ZoneOffset.UTC).toLocalDate() + val filters = newsViewModel.uiState.value.filters + assertThat(filters.startDateSort).isEqualTo(expectedDate) + assertThat(filters.endDateSort).isEqualTo(expectedDate) + } + + @Test + fun `When ApplyFilters event is called, Then update news with new filters`() = runTest { + // GIVEN + initViewModel() + val dateInMillis = 1714399712434L + + // WHEN + newsViewModel.onEvent(event = NewsEvent.ChangeDate(newDateInMillis = dateInMillis, dateMomentType = DateMomentType.Start)) + newsViewModel.onEvent(event = NewsEvent.ChangeSymbol(symbolToChange = newsViewModel.uiState.value.filters.symbols.last())) + advanceUntilIdle() + newsViewModel.onEvent(event = NewsEvent.ApplyFilters) + advanceUntilIdle() + + // THEN + val expectedDate = Instant.ofEpochMilli(dateInMillis).atZone(ZoneOffset.UTC).toLocalDate() + coVerify(exactly = 2) { + getNews.invoke( + startDate = expectedDate.atStartOfDay(), + symbols = listOf(newsViewModel.uiState.value.filters.symbols.last().symbol), + pageToken = any(), endDate = any(), sortType = any() + ) + } + verify { + changeFilterRealTimeNews.invoke( + subscribeSymbols = any(), + unsubscribeSymbols = any(), + ) + } + } + + @Test + fun `When ApplyFilters event is called and filters are the same, Then just ignore`() = runTest { + // GIVEN + initViewModel() + + // WHEN + newsViewModel.onEvent(event = NewsEvent.ApplyFilters) + advanceUntilIdle() + + // THEN + coVerify(exactly = 1) { // 1 is because GetNews is called in INIT + getNews.invoke() + } + verify(exactly = 0) { + changeFilterRealTimeNews.invoke() + } + } + + @Test + fun `When ApplyFilters event is called and filters don't change in symbols, Then real time changeFilterRealTimeNews is not called`() = runTest { + // GIVEN + initViewModel() + val dateInMillis = 1714399712434L + + // WHEN + newsViewModel.onEvent(event = NewsEvent.ChangeDate(newDateInMillis = dateInMillis, dateMomentType = DateMomentType.Start)) + newsViewModel.onEvent(event = NewsEvent.ApplyFilters) + advanceUntilIdle() + + // THEN + coVerify(exactly = 2) { + getNews.invoke( + startDate = any() + ) + } + verify(exactly = 0) { + changeFilterRealTimeNews.invoke() + } + } + + @Test + fun `When ClearAllFilters event is called, Then update news related to base filters`() = runTest { + // GIVEN + initViewModel() + val dateInMillis = 1714399712434L + + // WHEN + newsViewModel.onEvent(event = NewsEvent.ChangeDate(newDateInMillis = dateInMillis, dateMomentType = DateMomentType.Start)) + newsViewModel.onEvent(event = NewsEvent.ApplyFilters) + newsViewModel.onEvent(event = NewsEvent.ClearAllFilters) + advanceUntilIdle() + + // THEN + val uiState = newsViewModel.uiState.value + assertThat(uiState.isToShowFilters).isFalse() + assertThat(uiState.filters).isEqualTo(NewsFilters()) + coVerify(exactly = 3) { + getNews.invoke( + startDate = any() + ) + } + } + + @Test + fun `When ClearAllFilters event is called but the filters applied are the base filters, Then just close the bottom sheet`() = runTest { + // GIVEN + initViewModel() + val dateInMillis = 1714399712434L + + // WHEN + newsViewModel.onEvent(event = NewsEvent.ChangeDate(newDateInMillis = dateInMillis, dateMomentType = DateMomentType.Start)) + newsViewModel.onEvent(event = NewsEvent.ClearAllFilters) + advanceUntilIdle() + + // THEN + val uiState = newsViewModel.uiState.value + assertThat(uiState.isToShowFilters).isFalse() + assertThat(uiState.filters).isEqualTo(NewsFilters()) + coVerify(exactly = 1) { + getNews.invoke() + } + } + + @Test + fun `When RevertFilters event is called, Then just revert filters to last filters`() = runTest { + // GIVEN + initViewModel() + val dateInMillis = 1714399712434L + + // WHEN + newsViewModel.onEvent(event = NewsEvent.ChangeDate(newDateInMillis = dateInMillis, dateMomentType = DateMomentType.Start)) + newsViewModel.onEvent(event = NewsEvent.ApplyFilters) + advanceUntilIdle() + val lastFilters = newsViewModel.uiState.value.filters + + newsViewModel.onEvent(event = NewsEvent.ChangeDate(newDateInMillis = null, dateMomentType = DateMomentType.Start)) + newsViewModel.onEvent(event = NewsEvent.RevertFilters) + advanceUntilIdle() + + // THEN + val uiState = newsViewModel.uiState.value + assertThat(uiState.isToShowFilters).isFalse() + assertThat(uiState.filters).isEqualTo(lastFilters) + } + + private fun mockChangeFilterRealTimeNewsError() { + coEvery { + changeFilterRealTimeNews.invoke( + subscribeSymbols = any(), + unsubscribeSymbols = any(), + ) + }.returns( + flow { + emit(Resource.Error(message = "Error")) + } + ) + } + + private fun mockResponseGetNewsSuccess( + news: List, + nextPageToken: String? = null, + ) { + coEvery { + getNews.invoke( + pageToken = any(), limitPerPage = any(), sortType = any(), + symbols = any(), startDate = any(), endDate = any(), + ) + }.coAnswers { + delay(1000) + Resource.Success(data = NewsResponse(news = news, nextPageToken = nextPageToken)) + } + } + + private fun mockResponseGetNewsError() { + coEvery { + getNews.invoke( + pageToken = any(), limitPerPage = any(), sortType = any(), + symbols = any(), startDate = any(), endDate = any(), + ) + }.returns( + Resource.Error(message = "Error on GetNews") + ) + } + + private fun mockResponseGetNewsErrorThenSuccess( + news: List, + ) { + coEvery { + getNews.invoke( + pageToken = any(), limitPerPage = any(), sortType = any(), + symbols = any(), startDate = any(), endDate = any(), + ) + }.returnsMany( + Resource.Error(message = "Error on GetNews"), + Resource.Success(data = NewsResponse(news = news, nextPageToken = null)) + ) + } + + private fun mockNetwork() { + coEvery { + connectivityObserver.observe() + }.returns( + flow { + emit(ConnectivityObserver.Status.Lost) + delay(1000) + emit(ConnectivityObserver.Status.Available) + } + ) + } + + private fun mockGetRealTimeNews( + news: List, + ) { + coEvery { + getRealTimeNews.invoke() + }.returns( + flow { + emit(news) + } + ) + } +} \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c820512..8097808 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -20,6 +20,7 @@ mockkVersion = "1.13.10" coroutineVersion = "1.8.0" assertkVersion = "0.28.0" fakerVersion = "1.0.2" +turbineVersion = "1.1.0" coilVersion = "2.6.0" pagingVersion = "3.2.1" @@ -87,6 +88,9 @@ assertk = { group = "com.willowtreeapps.assertk", name = "assertk", version.ref mockk-android = { group = "io.mockk", name = "mockk-android", version.ref = "mockkVersion" } mockk-agent = { group = "io.mockk", name = "mockk-agent", version.ref = "mockkVersion" } +# TURBINE +turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbineVersion"} + # END-TO-END TEST androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } From fa5c993d5da53dbfd724b50acb45c8a57cdc6fef Mon Sep 17 00:00:00 2001 From: LucasPrioste Date: Mon, 29 Apr 2024 16:59:22 +0100 Subject: [PATCH 23/24] - update libs --- app/build.gradle.kts | 2 +- gradle/libs.versions.toml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 38a162b..e1f7448 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -61,7 +61,7 @@ android { buildConfig = true } composeOptions { - kotlinCompilerExtensionVersion = "1.5.10" + kotlinCompilerExtensionVersion = "1.5.12" } packaging { resources { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8097808..e298319 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,12 +1,12 @@ [versions] -agp = "8.3.1" -kotlin = "1.9.22" -coreKtx = "1.12.0" +agp = "8.3.2" +kotlin = "1.9.23" +coreKtx = "1.13.0" junitVersion = "1.1.5" espressoCore = "3.5.1" lifecycleRuntimeKtx = "2.7.0" -activityCompose = "1.8.2" -composeBom = "2024.04.00" +activityCompose = "1.9.0" +composeBom = "2024.04.01" retrofitVersion = "2.11.0" okHttpVersion = "4.12.0" scarletVersion = "0.1.12" From 232d958a0739b3890d24fb5bc328c5709ee7fb2a Mon Sep 17 00:00:00 2001 From: LucasPrioste Date: Mon, 29 Apr 2024 17:23:47 +0100 Subject: [PATCH 24/24] - update symbol crypto --- app/src/main/java/dev/pinkroom/marketsight/common/Utils.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/dev/pinkroom/marketsight/common/Utils.kt b/app/src/main/java/dev/pinkroom/marketsight/common/Utils.kt index 9ecd060..4aad853 100644 --- a/app/src/main/java/dev/pinkroom/marketsight/common/Utils.kt +++ b/app/src/main/java/dev/pinkroom/marketsight/common/Utils.kt @@ -121,17 +121,17 @@ val popularSymbols = listOf( ), SubInfoSymbols( name = "Bitcoin", - symbol = "BTC", + symbol = "BTCUSD", isSubscribed = false, ), SubInfoSymbols( name = "Ethereum", - symbol = "ETH", + symbol = "ETHUSD", isSubscribed = false, ), SubInfoSymbols( name = "Shiba", - symbol = "SHIB", + symbol = "SHIBUSD", isSubscribed = false, ), ) \ No newline at end of file