diff --git a/app/build.gradle b/app/build.gradle index 6011a7d..aeac94e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -19,13 +19,11 @@ android { defaultConfig { applicationId "com.cornellappdev.volume" minSdk 27 - targetSdk 32 + targetSdk 33 versionCode 1 versionName "1.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - buildConfigField("String", "PROD_ENDPOINT", secretsProperties['PROD_ENDPOINT']) - buildConfigField("String", "DEV_ENDPOINT", secretsProperties['DEV_ENDPOINT']) buildConfigField("String", "FEEDBACK_FORM", secretsProperties['FEEDBACK_FORM']) buildConfigField("String", "WEBSITE", secretsProperties['WEBSITE']) @@ -39,9 +37,11 @@ android { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' resValue("bool", "FIREBASE_ANALYTICS_DEACTIVATED", "false") + buildConfigField("String", "ENDPOINT", secretsProperties['PROD_ENDPOINT']) } debug { resValue("bool", "FIREBASE_ANALYTICS_DEACTIVATED", "true") + buildConfigField("String", "ENDPOINT", secretsProperties['DEV_ENDPOINT']) } } @@ -107,11 +107,12 @@ dependencies { implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1' // Firebase - implementation platform('com.google.firebase:firebase-bom:27.0.0') + implementation platform('com.google.firebase:firebase-bom:31.0.2') implementation 'com.google.firebase:firebase-inappmessaging-display-ktx' + implementation 'com.vmadalin:easypermissions-ktx:1.0.0' + implementation 'com.google.accompanist:accompanist-permissions:0.27.0' implementation 'com.google.firebase:firebase-messaging-ktx' implementation 'com.google.firebase:firebase-analytics-ktx' - implementation 'com.google.firebase:firebase-installations:17.0.2' // Accompanist implementation 'com.google.accompanist:accompanist-pager:0.26.2-beta' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9543b58..6ae5f3d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,7 @@ + + + @@ -27,7 +32,6 @@ - @@ -46,6 +50,17 @@ android:host="weekly_debrief" android:scheme="volume" /> + + + + + + + + + + currentPreferences.toBuilder().setNotificationFlowCompleted(value).build() + } + } + suspend fun addBookmarkedArticle(articleId: String) { userPreferencesStore.updateData { currentPreferences -> val currentBookmarks = currentPreferences.bookmarkedArticlesList.toHashSet() @@ -78,4 +84,7 @@ class UserPreferencesRepository @Inject constructor( suspend fun fetchShoutoutCount(articleId: String): Int = userPreferencesFlow.first().shoutoutMap.getOrDefault(articleId, 0) + + suspend fun fetchNotificationFlowStatus(): Boolean = + userPreferencesFlow.first().notificationFlowCompleted } diff --git a/app/src/main/java/com/cornellappdev/volume/di/NetworkModule.kt b/app/src/main/java/com/cornellappdev/volume/di/NetworkModule.kt index d431367..c803a4f 100644 --- a/app/src/main/java/com/cornellappdev/volume/di/NetworkModule.kt +++ b/app/src/main/java/com/cornellappdev/volume/di/NetworkModule.kt @@ -17,7 +17,7 @@ import javax.inject.Singleton @InstallIn(SingletonComponent::class) object NetworkModule { - private const val ENDPOINT = BuildConfig.DEV_ENDPOINT + private const val ENDPOINT = BuildConfig.ENDPOINT @Singleton @Provides diff --git a/app/src/main/java/com/cornellappdev/volume/navigation/MainTabbedNavigation.kt b/app/src/main/java/com/cornellappdev/volume/navigation/MainTabbedNavigation.kt index 350d0a6..38f8e85 100644 --- a/app/src/main/java/com/cornellappdev/volume/navigation/MainTabbedNavigation.kt +++ b/app/src/main/java/com/cornellappdev/volume/navigation/MainTabbedNavigation.kt @@ -5,6 +5,7 @@ import androidx.compose.animation.core.tween import androidx.compose.foundation.layout.padding import androidx.compose.material.* import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable @@ -51,6 +52,9 @@ fun TabbedNavigationSetup(onboardingCompleted: Boolean) { Routes.BOOKMARKS.route -> { showBottomBar.value = true } + Routes.SETTINGS.route -> { + showBottomBar.value = false + } Routes.ABOUT_US.route -> { showBottomBar.value = false } @@ -86,6 +90,7 @@ fun TabbedNavigationSetup(onboardingCompleted: Boolean) { modifier = Modifier.padding(innerPadding), isOnboardingCompleted = onboardingCompleted, navController = navController, + showBottomBar = showBottomBar ) } } @@ -106,7 +111,9 @@ fun BottomNavigationBar(navController: NavHostController, tabItems: List, ) { // The starting destination switches to onboarding if it isn't completed. AnimatedNavHost( @@ -152,8 +161,11 @@ private fun MainScreenNavigationConfigurations( }) { HomeScreen( onArticleClick = { article, navigationSource -> + FirstTimeShown.firstTimeShown = false navController.navigate("${Routes.OPEN_ARTICLE.route}/${article.id}/${navigationSource.name}") - }) + }, + showBottomBar = showBottomBar, + ) } composable(route = Routes.WEEKLY_DEBRIEF.route, deepLinks = listOf( navDeepLink { uriPattern = "volume://${Routes.WEEKLY_DEBRIEF.route}" } @@ -218,6 +230,22 @@ private fun MainScreenNavigationConfigurations( ) } composable(Routes.MAGAZINES.route) {} + composable( + route = "${Routes.OPEN_MAGAZINE.route}/{magazineId}/{navigationSourceName}", + deepLinks = listOf( + navDeepLink { uriPattern = "volume://${Routes.OPEN_MAGAZINE.route}/{magazineId}" } + ), + enterTransition = { + fadeIn( + initialAlpha = 0f, + animationSpec = tween(durationMillis = 1500) + ) + }, + exitTransition = { + fadeOut( + animationSpec = tween(durationMillis = 1500) + ) + }) {} composable(Routes.PUBLICATIONS.route) { PublicationsScreen( onPublicationClick = @@ -227,7 +255,8 @@ private fun MainScreenNavigationConfigurations( ) } - composable(Routes.BOOKMARKS.route, + composable( + Routes.BOOKMARKS.route, enterTransition = { fadeIn( initialAlpha = 0f, diff --git a/app/src/main/java/com/cornellappdev/volume/navigation/NavigationItem.kt b/app/src/main/java/com/cornellappdev/volume/navigation/NavigationItem.kt index ceacb4a..d0fb0cd 100644 --- a/app/src/main/java/com/cornellappdev/volume/navigation/NavigationItem.kt +++ b/app/src/main/java/com/cornellappdev/volume/navigation/NavigationItem.kt @@ -9,46 +9,54 @@ import com.cornellappdev.volume.R * @property unselectedIconId represents the resource id number for the tab icon when not selected * @property selectedIconId represents the resource id number for the tab icon when selected * @property title title of tab + * @property selectedRoutes represents the routes the tab should be considered selected for */ sealed class NavigationItem( val route: String, val unselectedIconId: Int, val selectedIconId: Int, - val title: String + val title: String, + val selectedRoutes: Set ) { object Home : NavigationItem( - Routes.HOME.route, - R.drawable.ic_volume_bars_gray, - R.drawable.ic_volume_bars_orange, - "For You" + route = Routes.HOME.route, + unselectedIconId = R.drawable.ic_volume_bars_gray, + selectedIconId = R.drawable.ic_volume_bars_orange, + title = "For You", + selectedRoutes = setOf(Routes.HOME.route, Routes.WEEKLY_DEBRIEF.route) ) object Magazines : NavigationItem( - Routes.MAGAZINES.route, - R.drawable.ic_magazine_icon_selected, - R.drawable.ic_magazine_icon_unselected, - "Magazines" + route = Routes.MAGAZINES.route, + unselectedIconId = R.drawable.ic_magazine_icon_unselected, + selectedIconId = R.drawable.ic_magazine_icon_selected, + title = "Magazines", + selectedRoutes = setOf(Routes.MAGAZINES.route) ) object Publications : NavigationItem( - Routes.PUBLICATIONS.route, - R.drawable.ic_publications_icon_selected, - R.drawable.ic_publications_icon_unselected, - "Publications" + route = Routes.PUBLICATIONS.route, + unselectedIconId = R.drawable.ic_publications_icon_unselected, + selectedIconId = R.drawable.ic_publications_icon_selected, + title = "Publications", + selectedRoutes = setOf( + Routes.PUBLICATIONS.route, + "${Routes.INDIVIDUAL_PUBLICATION.route}/{publicationSlug}" + ) ) - object Bookmarks : - NavigationItem( - Routes.BOOKMARKS.route, - R.drawable.ic_bookmark_gray, - R.drawable.ic_bookmark_orange, - "Saved" - ) + object Bookmarks : NavigationItem( + route = Routes.BOOKMARKS.route, + unselectedIconId = R.drawable.ic_bookmark_gray, + selectedIconId = R.drawable.ic_bookmark_orange, + title = "Bookmarks", + selectedRoutes = setOf(Routes.BOOKMARKS.route) + ) companion object { val bottomNavTabList = listOf( Home, - Magazines, +// Magazines, Publications, Bookmarks ) @@ -75,6 +83,7 @@ enum class Routes(override var route: String) : NavUnit { ONBOARDING("onboarding"), INDIVIDUAL_PUBLICATION("individual"), OPEN_ARTICLE("open_article"), + OPEN_MAGAZINE("open_magazine"), SETTINGS("settings"), ABOUT_US("about_us"), WEEKLY_DEBRIEF("weekly_debrief") diff --git a/app/src/main/java/com/cornellappdev/volume/ui/components/general/ArticleComponents.kt b/app/src/main/java/com/cornellappdev/volume/ui/components/general/ArticleComponents.kt index 1c06589..af1325a 100644 --- a/app/src/main/java/com/cornellappdev/volume/ui/components/general/ArticleComponents.kt +++ b/app/src/main/java/com/cornellappdev/volume/ui/components/general/ArticleComponents.kt @@ -29,7 +29,7 @@ import com.cornellappdev.volume.ui.theme.* */ @OptIn(ExperimentalComposeUiApi::class) @Composable -fun CreateHorizontalArticleRow( +fun CreateArticleRow( article: Article, isABookmarkedArticle: Boolean = false, onClick: (Article) -> Unit diff --git a/app/src/main/java/com/cornellappdev/volume/ui/components/general/IndividualPublicationComponents.kt b/app/src/main/java/com/cornellappdev/volume/ui/components/general/IndividualPublicationComponents.kt index 25764d6..caa9346 100644 --- a/app/src/main/java/com/cornellappdev/volume/ui/components/general/IndividualPublicationComponents.kt +++ b/app/src/main/java/com/cornellappdev/volume/ui/components/general/IndividualPublicationComponents.kt @@ -2,29 +2,30 @@ package com.cornellappdev.volume.ui.components.general import androidx.compose.animation.Crossfade import androidx.compose.foundation.Image +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.ClickableText import androidx.compose.material.Button import androidx.compose.material.ButtonDefaults import androidx.compose.material.Icon import androidx.compose.material.Text import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.scale +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextDecoration @@ -34,6 +35,8 @@ import androidx.compose.ui.unit.sp import coil.compose.AsyncImage import com.cornellappdev.volume.R import com.cornellappdev.volume.data.models.Publication +import com.cornellappdev.volume.data.models.Social.Companion.formattedSocialNameMap +import com.cornellappdev.volume.data.models.Social.Companion.socialLogoMap import com.cornellappdev.volume.ui.theme.* @Composable @@ -49,7 +52,6 @@ fun CreateIndividualPublicationHeading( .wrapContentHeight() ) { Box { - AsyncImage( model = publication.backgroundImageURL, contentScale = ContentScale.Crop, @@ -61,214 +63,165 @@ fun CreateIndividualPublicationHeading( AsyncImage( model = publication.profileImageURL, contentScale = ContentScale.Crop, + alignment = Alignment.TopStart, modifier = Modifier - .align(Alignment.TopStart) - .padding(start = (8.dp), top = 130.dp) + .padding(start = 8.dp, top = 130.dp) .size(64.dp) - .clip(CircleShape), + .clip(CircleShape) + .shadow(4.dp, CircleShape), contentDescription = null ) } - Row( - modifier = Modifier - .padding(top = 10.dp) - .fillMaxWidth() + + Column( + modifier = Modifier.padding(horizontal = 12.dp) ) { - Text( - modifier = Modifier - .padding(start = 12.dp, top = 2.dp) - .wrapContentHeight() - .width(230.dp), - text = publication.name, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - fontFamily = notoserif, - fontWeight = FontWeight.Medium, - fontSize = 18.sp - ) - Button( + Row( modifier = Modifier - .padding(start = 30.dp) - .height(33.dp), - onClick = { - hasBeenClicked.value = !hasBeenClicked.value - followButtonClicked(hasBeenClicked.value) - }, - shape = RoundedCornerShape(5.dp), - colors = ButtonDefaults.buttonColors(backgroundColor = if (hasBeenClicked.value) VolumeOrange else GrayThree), + .padding(top = 10.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween ) { - Crossfade(targetState = hasBeenClicked.value) { hasBeenClicked -> - if (hasBeenClicked) { - Text( - text = "Following", - fontFamily = lato, - fontWeight = FontWeight.SemiBold, - fontSize = 12.sp, - color = GrayThree - ) - } else { - Row(verticalAlignment = Alignment.CenterVertically) { - Icon( - Icons.Default.Add, - contentDescription = "Follow", - modifier = Modifier.scale(.8f), - tint = VolumeOrange - ) + Text( + modifier = Modifier + .weight(1f) + .padding(end = 20.dp), + text = publication.name, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + fontFamily = notoserif, + fontWeight = FontWeight.Medium, + fontSize = 18.sp + ) + + Button( + modifier = Modifier + .height(33.dp), + onClick = { + hasBeenClicked.value = !hasBeenClicked.value + followButtonClicked(hasBeenClicked.value) + }, + shape = RoundedCornerShape(5.dp), + colors = ButtonDefaults.buttonColors(backgroundColor = if (hasBeenClicked.value) VolumeOrange else GrayThree), + ) { + Crossfade(targetState = hasBeenClicked.value) { hasBeenClicked -> + if (hasBeenClicked) { Text( - modifier = Modifier.padding(start = 6.dp), - text = "Follow", - textAlign = TextAlign.Center, + text = "Following", fontFamily = lato, fontWeight = FontWeight.SemiBold, fontSize = 12.sp, - color = VolumeOrange + color = GrayThree ) + } else { + Row(verticalAlignment = Alignment.CenterVertically) { + Icon( + Icons.Default.Add, + contentDescription = "Follow", + tint = VolumeOrange + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text( + text = "Follow", + textAlign = TextAlign.Center, + fontFamily = lato, + fontWeight = FontWeight.SemiBold, + fontSize = 12.sp, + color = VolumeOrange + ) + } } - } } } - } - Text( - modifier = Modifier.padding(start = 12.dp), - text = "${publication.numArticles.toInt()} articles · ${publication.shoutouts.toInt()} shoutouts", - fontFamily = lato, - fontWeight = FontWeight.Medium, - fontSize = 10.sp, - color = GrayOne - ) - - Text( - modifier = Modifier.padding(start = 12.dp, top = 2.dp, end = 20.dp), - text = publication.bio, - maxLines = 6, - overflow = TextOverflow.Ellipsis, - fontFamily = lato, - fontWeight = FontWeight.Medium, - fontSize = 14.sp - ) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 10.dp, start = 12.dp, end = 12.dp), - horizontalArrangement = Arrangement.Start - ) { - for (social in publication.socials) { - if (social.social == "instagram") { - Image( - modifier = Modifier - .scale(1.3f), - painter = painterResource(R.drawable.ic_instagram), - contentDescription = null, - ) - HyperlinkText( - fullText = "Instagram", - modifier = Modifier.padding(start = 10.dp), - hyperLinks = Pair("Instagram", social.url), - style = TextStyle(fontFamily = lato, color = VolumeOrange) - ) - } - if (social.social == "facebook") { - Image( - modifier = Modifier - .scale(1.3f), - painter = painterResource(R.drawable.ic_facebook), - contentDescription = null, - ) - HyperlinkText( - fullText = "Facebook", - modifier = Modifier.padding(start = 10.dp), - hyperLinks = Pair("Facebook", social.url), - style = TextStyle(fontFamily = lato, color = VolumeOrange) - ) - } - } - Image( - modifier = Modifier - .padding(start = 10.dp) - .scale(1.3f), - painter = painterResource(R.drawable.ic_link), - contentDescription = null, + Text( + text = "${publication.numArticles.toInt()} articles · ${publication.shoutouts.toInt()} shoutouts", + fontFamily = lato, + fontWeight = FontWeight.Medium, + fontSize = 10.sp, + color = GrayOne ) - val httpsIndex = publication.websiteURL.indexOf("http") + 8 - val wwwIndex = publication.websiteURL.indexOf("www") - var endIndex = Math.max( - publication.websiteURL.indexOf("com") + 3, - publication.websiteURL.indexOf("org") + 3 + + Text( + modifier = Modifier.padding(top = 2.dp), + text = publication.bio, + maxLines = 6, + overflow = TextOverflow.Ellipsis, + fontFamily = lato, + fontWeight = FontWeight.Medium, + fontSize = 14.sp ) - endIndex = Math.max(endIndex, publication.websiteURL.indexOf("al") + 2) - endIndex = Math.max(endIndex, publication.websiteURL.indexOf("edu") + 3) - endIndex = Math.max(endIndex, publication.websiteURL.indexOf("net") + 3) - var urlString: String = if (wwwIndex == -1) { - "www." + publication.websiteURL.substring(httpsIndex, endIndex) - } else { - publication.websiteURL.substring(wwwIndex, endIndex) - } - HyperlinkText( - fullText = urlString, - modifier = Modifier.padding(start = 10.dp), - hyperLinks = Pair(publication.name, publication.websiteURL), - style = TextStyle( - fontFamily = lato, - color = VolumeOrange, - textDecoration = TextDecoration.Underline + + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(13.dp) + ) { + for (social in publication.socials) { + val socialName = + formattedSocialNameMap.getOrDefault(social.social, social.social) + + Row { + // Make sure that the drawable is in the socialLogoMap or the painter is null + HyperlinkText( + displayText = socialName, + uri = social.url, + style = TextStyle(fontFamily = lato, color = VolumeOrange), + painter = socialLogoMap[socialName]?.let { painterResource(it) }, + ) + } + } + + val websiteURL = + publication.websiteURL.removePrefix("https://").removePrefix("http://") + .removePrefix("www.").removeSuffix("/") + + HyperlinkText( + displayText = websiteURL, + uri = publication.websiteURL, + style = TextStyle( + fontFamily = lato, + color = VolumeOrange, + textDecoration = TextDecoration.Underline + ), + painter = painterResource(R.drawable.ic_link), ) - ) + } } - } } @Composable fun HyperlinkText( - modifier: Modifier = Modifier, - fullText: String, - hyperLinks: Pair, - style: TextStyle + displayText: String, + uri: String, + style: TextStyle, + painter: Painter? ) { - val annotatedString = buildAnnotatedString { - append(fullText) - val startIndex = fullText.indexOf(hyperLinks.first) - val endIndex = startIndex + hyperLinks.first.length - addStyle( - style = SpanStyle( - color = VolumeOrange, - fontSize = 12.sp, - ), - start = startIndex, - end = endIndex - ) - addStringAnnotation( - tag = "URL", - annotation = hyperLinks.second, - start = startIndex, - end = endIndex - ) - - addStyle( - style = SpanStyle( - fontSize = 12.sp - ), - start = 0, - end = fullText.length - ) - } - val uriHandler = LocalUriHandler.current - ClickableText( - modifier = modifier, - text = annotatedString, - maxLines = 1, - style = style, - overflow = TextOverflow.Ellipsis, + TextButton( + contentPadding = PaddingValues(0.dp), onClick = { - annotatedString - .getStringAnnotations("URL", it, it) - .firstOrNull()?.let { stringAnnotation -> - uriHandler.openUri(stringAnnotation.item) - } - }, - ) + uriHandler.openUri(uri) + } + ) { + if (painter != null) { + Image( + painter = painter, + contentDescription = "Icon", + ) + + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + } + + Text( + text = displayText, + maxLines = 1, + style = style, + overflow = TextOverflow.Ellipsis, + ) + } } diff --git a/app/src/main/java/com/cornellappdev/volume/ui/components/general/PermissionRequestDialog.kt b/app/src/main/java/com/cornellappdev/volume/ui/components/general/PermissionRequestDialog.kt new file mode 100644 index 0000000..2781f45 --- /dev/null +++ b/app/src/main/java/com/cornellappdev/volume/ui/components/general/PermissionRequestDialog.kt @@ -0,0 +1,134 @@ +package com.cornellappdev.volume.ui.components.general + +import android.Manifest +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.provider.Settings +import android.util.Log +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.cornellappdev.volume.ui.theme.VolumeOrange +import com.cornellappdev.volume.ui.theme.lato +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.PermissionStatus +import com.google.accompanist.permissions.rememberPermissionState + +/** + * Creates a Permission request dialog for requesting POST_NOTIFICATIONS permission. + * + * We only need to request the permissions if the Android phone is on version 13 or above. + */ +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun PermissionRequestDialog( + showBottomBar: MutableState, + notificationFlowStatus: Boolean, + updateNotificationFlowStatus: (Boolean) -> Unit +) { + var requestingPermission by remember { mutableStateOf(Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) } + showBottomBar.value = !requestingPermission + + AnimatedVisibility( + visible = requestingPermission, + enter = fadeIn(), + exit = fadeOut() + ) { + val context = LocalContext.current + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val notificationPermissionState = + rememberPermissionState(permission = Manifest.permission.POST_NOTIFICATIONS) + + when (val permissionStatus = notificationPermissionState.status) { + is PermissionStatus.Granted -> { + requestingPermission = false + } + is PermissionStatus.Denied -> { + Log.d("HomeScreen", "Showing rationale") + + Surface( + color = Color.Black.copy(alpha = 0.6f), + modifier = Modifier.fillMaxSize() + ) { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Card( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .padding(horizontal = 20.dp), + elevation = 10.dp + ) { + Column( + modifier = Modifier.padding(33.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = "Notifications are necessary to send you " + + "updates about new articles and magazines by publishers you follow and the Weekly Debrief! " + + if (permissionStatus.shouldShowRationale || !notificationFlowStatus) { + "" + } else { + "\n\nPlease click the button below to go to the settings to enable notifications." + }, + textAlign = TextAlign.Center, + fontFamily = lato, + fontWeight = FontWeight.Medium + ) + Spacer(modifier = Modifier.height(15.dp)) + Button( + onClick = { + if (permissionStatus.shouldShowRationale || !notificationFlowStatus) { + notificationPermissionState.launchPermissionRequest() + updateNotificationFlowStatus(true) + } else { + context.openSettings() + } + requestingPermission = false + }, + shape = RoundedCornerShape(5.dp), + colors = ButtonDefaults.buttonColors(backgroundColor = VolumeOrange), + ) { + Text( + text = if (permissionStatus.shouldShowRationale || !notificationFlowStatus) { + "Request Permission" + } else { + "Open Settings" + }, + color = Color.White, + fontFamily = lato + ) + } + } + } + } + } + } + } + } + } +} + +fun Context.openSettings() { + val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + intent.data = Uri.fromParts("package", packageName, null) + startActivity(intent) +} diff --git a/app/src/main/java/com/cornellappdev/volume/ui/components/general/PublicationComponents.kt b/app/src/main/java/com/cornellappdev/volume/ui/components/general/PublicationComponents.kt index 36bd9cb..f24461f 100644 --- a/app/src/main/java/com/cornellappdev/volume/ui/components/general/PublicationComponents.kt +++ b/app/src/main/java/com/cornellappdev/volume/ui/components/general/PublicationComponents.kt @@ -13,6 +13,7 @@ import androidx.compose.material.icons.filled.Done import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color @@ -36,7 +37,7 @@ import com.cornellappdev.volume.ui.theme.* * @param followButtonClicked */ @Composable -fun CreateHorizontalPublicationRow( +fun CreatePublicationRow( publication: Publication, followButtonClicked: (Publication, Boolean) -> Unit, ) { @@ -156,12 +157,12 @@ fun CreateHorizontalPublicationRow( } @Composable -fun CreateHorizontalPublicationRowFollowing( +fun CreatePublicationRow( publication: Publication, onPublicationClick: (Publication) -> Unit, - followButtonClicked: (Publication, Boolean) -> Unit, + followButtonClicked: (Publication, Boolean) -> Unit +) { - ) { var hasBeenClicked = false Row( modifier = Modifier @@ -224,7 +225,6 @@ fun CreateHorizontalPublicationRowFollowing( } } } - } Row( @@ -233,7 +233,6 @@ fun CreateHorizontalPublicationRowFollowing( .height(IntrinsicSize.Min) .padding(bottom = 2.dp) ) { - Text( modifier = Modifier.padding(end = 20.dp), text = publication.bio, @@ -281,26 +280,27 @@ fun CreateHorizontalPublicationRowFollowing( } @Composable -fun CreateFollowPublicationRow( +fun CreatePublicationColumn( publication: Publication, onPublicationClick: (Publication) -> Unit ) { val title = publication.name - Column(modifier = Modifier - .wrapContentHeight() - .width(100.dp) - .clickable { - onPublicationClick(publication) - }) { - + Column( + modifier = Modifier + .wrapContentHeight() + .width(100.dp) + .clickable { + onPublicationClick(publication) + }, + horizontalAlignment = Alignment.CenterHorizontally + ) { AsyncImage( model = publication.profileImageURL, modifier = Modifier .height(100.dp) .width(100.dp) .clip(CircleShape), contentDescription = null, contentScale = ContentScale.Crop ) - Text( modifier = Modifier.padding(bottom = 2.dp, top = 2.dp), text = title, diff --git a/app/src/main/java/com/cornellappdev/volume/ui/components/onboarding/SecondPage.kt b/app/src/main/java/com/cornellappdev/volume/ui/components/onboarding/SecondPage.kt index 293a64c..d557ec5 100644 --- a/app/src/main/java/com/cornellappdev/volume/ui/components/onboarding/SecondPage.kt +++ b/app/src/main/java/com/cornellappdev/volume/ui/components/onboarding/SecondPage.kt @@ -35,7 +35,7 @@ import com.cornellappdev.volume.R import com.cornellappdev.volume.analytics.EventType import com.cornellappdev.volume.analytics.NavigationSource import com.cornellappdev.volume.analytics.VolumeEvent -import com.cornellappdev.volume.ui.components.general.CreateHorizontalPublicationRow +import com.cornellappdev.volume.ui.components.general.CreatePublicationRow import com.cornellappdev.volume.ui.states.PublicationsRetrievalState import com.cornellappdev.volume.ui.theme.* import com.cornellappdev.volume.ui.viewmodels.OnboardingViewModel @@ -130,7 +130,7 @@ fun SecondPage( ) { publication -> // Clicking on row IN onboarding should not lead to IndividualPublicationScreen. They are not // an official user yet so they shouldn't be interacting with the articles. - CreateHorizontalPublicationRow(publication = publication) { publicationFromCallback, isFollowing -> + CreatePublicationRow(publication = publication) { publicationFromCallback, isFollowing -> if (isFollowing) { onboardingViewModel.addPublicationToFollowed( publicationFromCallback.slug diff --git a/app/src/main/java/com/cornellappdev/volume/ui/screens/BookmarkScreen.kt b/app/src/main/java/com/cornellappdev/volume/ui/screens/BookmarkScreen.kt index 6b7164f..38fd7c8 100644 --- a/app/src/main/java/com/cornellappdev/volume/ui/screens/BookmarkScreen.kt +++ b/app/src/main/java/com/cornellappdev/volume/ui/screens/BookmarkScreen.kt @@ -29,7 +29,7 @@ import androidx.lifecycle.SavedStateHandle import com.cornellappdev.volume.R import com.cornellappdev.volume.analytics.NavigationSource import com.cornellappdev.volume.data.models.Article -import com.cornellappdev.volume.ui.components.general.CreateHorizontalArticleRow +import com.cornellappdev.volume.ui.components.general.CreateArticleRow import com.cornellappdev.volume.ui.states.ArticlesRetrievalState import com.cornellappdev.volume.ui.theme.VolumeOffWhite import com.cornellappdev.volume.ui.theme.VolumeOrange @@ -60,11 +60,11 @@ fun BookmarkScreen( } Scaffold(topBar = { - - Row(verticalAlignment = Alignment.CenterVertically) { - Row { + Row( + modifier = Modifier.padding(start = 12.dp, top = 20.dp), + ) { + Row(verticalAlignment = Alignment.Bottom) { Text( - modifier = Modifier.padding(start = 12.dp, top = 20.dp), text = "Bookmarks", fontFamily = notoserif, fontWeight = FontWeight.Medium, @@ -75,7 +75,7 @@ fun BookmarkScreen( painter = painterResource(R.drawable.ic_period), contentDescription = null, modifier = Modifier - .padding(start = 3.dp, top = 43.5.dp) + .padding(start = 3.dp, bottom = 10.dp) .scale(1.05F) ) } @@ -84,8 +84,7 @@ fun BookmarkScreen( Icon( Icons.Filled.Settings, contentDescription = "Settings", - tint = Color(0xFF838383), - modifier = Modifier.padding(top = 10.dp) + tint = Color(0xFF838383) ) } } @@ -105,115 +104,114 @@ fun BookmarkScreen( // TODO } is ArticlesRetrievalState.Success -> { - if (articleState.articles.isEmpty()) { - Column( - modifier = Modifier.fillMaxSize(), - verticalArrangement = Arrangement.Center, - horizontalAlignment = Alignment.CenterHorizontally - ) { - Image( - painter = painterResource(id = R.drawable.ic_volume_bars_orange_large), - contentDescription = null - ) - + Column { + Column(modifier = Modifier.padding(start = 12.dp, top = 15.dp)) { Text( - text = "Nothing to see here!", + text = "Saved Articles", fontFamily = notoserif, - fontSize = 24.sp, + fontSize = 20.sp, fontWeight = FontWeight.Medium ) - - Text( - text = "You have no saved articles.", - fontFamily = lato, - fontSize = 12.sp, - fontWeight = FontWeight.Medium + Image( + painter = painterResource(R.drawable.ic_underline_other_article), + contentDescription = null, + modifier = Modifier + .padding(start = 4.dp) + .scale(1.05F) ) } - } else { - Column( - modifier = Modifier - .padding(horizontal = 12.dp), - ) { - Box { + + if (articleState.articles.isEmpty()) { + Column( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + painter = painterResource(id = R.drawable.ic_volume_bars_orange_large), + contentDescription = null + ) + Text( - text = "Saved Articles", + text = "Nothing to see here!", fontFamily = notoserif, - fontSize = 20.sp, - fontWeight = FontWeight.Medium, - modifier = Modifier.padding(top = 25.dp) + fontSize = 24.sp, + fontWeight = FontWeight.Medium ) - Image( - painter = painterResource(R.drawable.ic_underline_other_article), - contentDescription = null, - modifier = Modifier - .padding(start = 2.dp, top = 50.dp) - .scale(1.05F) + + Text( + text = "You have no saved articles.", + fontFamily = lato, + fontSize = 12.sp, + fontWeight = FontWeight.Medium ) } - - Spacer(modifier = Modifier.height(20.dp)) - - LazyColumn( + } else { + Column( modifier = Modifier - .fillMaxSize(), - verticalArrangement = Arrangement.spacedBy(20.dp), + .padding(start = 12.dp, end = 12.dp, top = 20.dp), ) { - items(articleState.articles) { article -> - val dismissState = rememberDismissState() - if (dismissState.isDismissed(DismissDirection.EndToStart)) { - bookmarkViewModel.removeArticle(article.id) - } + LazyColumn( + modifier = Modifier + .fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + items(articleState.articles) { article -> + val dismissState = rememberDismissState() + if (dismissState.isDismissed(DismissDirection.EndToStart)) { + bookmarkViewModel.removeArticle(article.id) + } - SwipeToDismiss( - state = dismissState, - directions = setOf( - DismissDirection.EndToStart - ), + SwipeToDismiss( + state = dismissState, + directions = setOf( + DismissDirection.EndToStart + ), - background = { - val backgroundColor by animateColorAsState( - when (dismissState.targetValue) { - DismissValue.Default -> VolumeOffWhite - else -> VolumeOrange - } - ) + background = { + val backgroundColor by animateColorAsState( + when (dismissState.targetValue) { + DismissValue.Default -> VolumeOffWhite + else -> VolumeOrange + } + ) - val iconColor by animateColorAsState( - when (dismissState.targetValue) { - DismissValue.Default -> VolumeOrange - else -> VolumeOffWhite - } - ) + val iconColor by animateColorAsState( + when (dismissState.targetValue) { + DismissValue.Default -> VolumeOrange + else -> VolumeOffWhite + } + ) - val size by animateDpAsState(targetValue = if (dismissState.targetValue == DismissValue.Default) 24.dp else 48.dp) + val size by animateDpAsState(targetValue = if (dismissState.targetValue == DismissValue.Default) 24.dp else 48.dp) - Box( - Modifier - .fillMaxSize() - .background(backgroundColor) - .padding(horizontal = Dp(20f)), - contentAlignment = Alignment.CenterEnd - ) { - Icon( - Icons.Filled.Bookmark, - contentDescription = "Unbookmark", - modifier = Modifier.size(size), - tint = iconColor - ) - } - }, - dismissContent = { - CreateHorizontalArticleRow( - article = article, - isABookmarkedArticle = true - ) { - onArticleClick( - article, - NavigationSource.BOOKMARK_ARTICLES - ) - } - }) + Box( + Modifier + .fillMaxSize() + .background(backgroundColor) + .padding(horizontal = Dp(20f)), + contentAlignment = Alignment.CenterEnd + ) { + Icon( + Icons.Filled.Bookmark, + contentDescription = "Unbookmark", + modifier = Modifier.size(size), + tint = iconColor + ) + } + }, + dismissContent = { + CreateArticleRow( + article = article, + isABookmarkedArticle = true + ) { + onArticleClick( + article, + NavigationSource.BOOKMARK_ARTICLES + ) + } + }) + } } } } diff --git a/app/src/main/java/com/cornellappdev/volume/ui/screens/HomeScreen.kt b/app/src/main/java/com/cornellappdev/volume/ui/screens/HomeScreen.kt index ceaf7cd..68e2272 100644 --- a/app/src/main/java/com/cornellappdev/volume/ui/screens/HomeScreen.kt +++ b/app/src/main/java/com/cornellappdev/volume/ui/screens/HomeScreen.kt @@ -24,8 +24,9 @@ import androidx.hilt.navigation.compose.hiltViewModel import com.cornellappdev.volume.R import com.cornellappdev.volume.analytics.NavigationSource import com.cornellappdev.volume.data.models.Article +import com.cornellappdev.volume.ui.components.general.CreateArticleRow import com.cornellappdev.volume.ui.components.general.CreateBigReadRow -import com.cornellappdev.volume.ui.components.general.CreateHorizontalArticleRow +import com.cornellappdev.volume.ui.components.general.PermissionRequestDialog import com.cornellappdev.volume.ui.states.ArticlesRetrievalState import com.cornellappdev.volume.ui.theme.VolumeOrange import com.cornellappdev.volume.ui.theme.lato @@ -35,267 +36,292 @@ import com.cornellappdev.volume.ui.viewmodels.HomeViewModel @Composable fun HomeScreen( homeViewModel: HomeViewModel = hiltViewModel(), - onArticleClick: (Article, NavigationSource) -> Unit + onArticleClick: (Article, NavigationSource) -> Unit, + showBottomBar: MutableState, ) { val homeUiState = homeViewModel.homeUiState var showPageBreak by remember { mutableStateOf(false) } - Scaffold(topBar = { - // TODO fix positioning, little weird on my phone not sure if that's the case universally - Image( - painter = painterResource(R.drawable.volume_title), - contentDescription = null, - modifier = Modifier - .scale(0.8f) - ) - }, content = { innerPadding -> - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(start = 12.dp, top = innerPadding.calculateTopPadding()), - ) { - item { - Box { - Text( - text = "The Big Read", - fontFamily = notoserif, - fontSize = 20.sp, - fontWeight = FontWeight.Medium, - modifier = Modifier.padding(top = 15.dp) - ) - Image( - painter = painterResource(R.drawable.ic_underline_big_read), - contentDescription = null, - modifier = Modifier - .padding(start = 2.dp, top = 40.dp) - .scale(1.05F) - ) + Box { + Scaffold(topBar = { + Image( + painter = painterResource(R.drawable.volume_title), + contentDescription = null, + modifier = Modifier + .scale(0.8f) + ) + }, content = { innerPadding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(start = 12.dp, top = innerPadding.calculateTopPadding()), + ) { + item { + Column { + Text( + text = "The Big Read", + fontFamily = notoserif, + fontSize = 20.sp, + fontWeight = FontWeight.Medium, + modifier = Modifier.padding(top = 15.dp) + ) + Image( + painter = painterResource(R.drawable.ic_underline_big_read), + contentDescription = null, + modifier = Modifier + .offset(y = (-5).dp) + .padding(start = 2.dp) + .scale(1.05F) + ) + } + Spacer(modifier = Modifier.height(25.dp)) } - Spacer(modifier = Modifier.height(25.dp)) - } - item { - when (val trendingArticlesState = homeUiState.trendingArticlesState) { - ArticlesRetrievalState.Loading -> { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - CircularProgressIndicator(color = VolumeOrange) + item { + when (val trendingArticlesState = homeUiState.trendingArticlesState) { + ArticlesRetrievalState.Loading -> { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + CircularProgressIndicator(color = VolumeOrange) + } } - } - ArticlesRetrievalState.Error -> { - // TODO Prompt to try again, queryTrendingArticles manually (it's public). Could be that internet is down. - } - is ArticlesRetrievalState.Success -> { - LazyRow(horizontalArrangement = Arrangement.spacedBy(24.dp)) { - items(trendingArticlesState.articles) { article -> - CreateBigReadRow(article) { - onArticleClick(article, NavigationSource.TRENDING_ARTICLES) + ArticlesRetrievalState.Error -> { + // TODO Prompt to try again, queryTrendingArticles manually (it's public). Could be that internet is down. + } + is ArticlesRetrievalState.Success -> { + LazyRow(horizontalArrangement = Arrangement.spacedBy(24.dp)) { + items(trendingArticlesState.articles) { article -> + CreateBigReadRow(article) { + onArticleClick( + article, + NavigationSource.TRENDING_ARTICLES + ) + } } } } } + Spacer(modifier = Modifier.height(25.dp)) } - Spacer(modifier = Modifier.height(25.dp)) - } - - item { - Box { - Text( - text = "Following", - fontFamily = notoserif, - fontSize = 20.sp, - fontWeight = FontWeight.Medium - ) - Image( - painter = painterResource(R.drawable.ic_underline_following), - contentDescription = null, - modifier = Modifier - .padding(start = 0.dp, top = 25.dp) - .scale(1.05F) - ) + item { + Column { + Text( + text = "Following", + fontFamily = notoserif, + fontSize = 20.sp, + fontWeight = FontWeight.Medium + ) + Image( + painter = painterResource(R.drawable.ic_underline_following), + contentDescription = null, + modifier = Modifier + .offset(y = (-5).dp) + .scale(1.05F) + ) + } } - } - item { - when (val followingArticlesState = homeUiState.followingArticlesState) { - ArticlesRetrievalState.Loading -> { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - CircularProgressIndicator( - color = VolumeOrange, - modifier = Modifier.padding(vertical = 50.dp) - ) - } - } - ArticlesRetrievalState.Error -> { - // TODO Prompt to try again, queryFollowingArticles manually (it's public). Could be that internet is down. - } - is ArticlesRetrievalState.Success -> { - Box(modifier = Modifier.padding(top = 10.dp)) { + item { + when (val followingArticlesState = homeUiState.followingArticlesState) { + ArticlesRetrievalState.Loading -> { Column( - modifier = Modifier - .wrapContentHeight() - .padding(end = 12.dp), - verticalArrangement = Arrangement.spacedBy(20.dp), + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally ) { - followingArticlesState.articles.forEach { article -> - CreateHorizontalArticleRow( - article - ) { - onArticleClick( - article, - NavigationSource.FOLLOWING_ARTICLES - ) + CircularProgressIndicator( + color = VolumeOrange, + modifier = Modifier.padding(vertical = 50.dp) + ) + } + } + ArticlesRetrievalState.Error -> { + // TODO Prompt to try again, queryFollowingArticles manually (it's public). Could be that internet is down. + } + is ArticlesRetrievalState.Success -> { + Box(modifier = Modifier.padding(top = 10.dp)) { + Column( + modifier = Modifier + .wrapContentHeight() + .padding(end = 12.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + followingArticlesState.articles.forEach { article -> + CreateArticleRow( + article + ) { + onArticleClick( + article, + NavigationSource.FOLLOWING_ARTICLES + ) + } } } + showPageBreak = true } - showPageBreak = true } } } - } - item { - AnimatedVisibility( - visible = showPageBreak, - enter = fadeIn(), - exit = fadeOut() - ) { - if (homeUiState.isFollowingEmpty) { - Column( - modifier = Modifier - .padding(vertical = 40.dp, horizontal = 16.dp) - .fillMaxWidth() - .wrapContentHeight(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Image( - painter = painterResource(id = R.drawable.ic_volume_bars_orange), - contentDescription = null, - ) - Box(modifier = Modifier.padding(top = 10.dp)) { - Text( - text = "Nothing to see here!", - fontFamily = notoserif, - fontSize = 16.sp, - fontWeight = FontWeight.Medium, - textAlign = TextAlign.Center - ) + item { + AnimatedVisibility( + visible = showPageBreak, + enter = fadeIn(), + exit = fadeOut() + ) { + if (homeUiState.isFollowingEmpty) { + Column( + modifier = Modifier + .padding(vertical = 40.dp, horizontal = 16.dp) + .fillMaxWidth() + .wrapContentHeight(), + horizontalAlignment = Alignment.CenterHorizontally + ) { Image( - painter = painterResource(R.drawable.ic_underline_nothing_new), + painter = painterResource(id = R.drawable.ic_volume_bars_orange), contentDescription = null, - modifier = Modifier - .padding(start = 5.dp, top = 20.dp) - .scale(1.05F) ) - } + Column(modifier = Modifier.padding(top = 10.dp)) { + Text( + text = "Nothing to see here!", + fontFamily = notoserif, + fontSize = 16.sp, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center + ) + Image( + painter = painterResource(R.drawable.ic_underline_nothing_new), + contentDescription = null, + modifier = Modifier + .padding(start = 5.dp) + .offset(y = (-5).dp) + .scale(1.05F) + ) + } - Text( - text = "Follow some student publications that you are interested in.", - fontFamily = lato, - fontSize = 12.sp, - fontWeight = FontWeight.Medium, - textAlign = TextAlign.Center, - modifier = Modifier.padding(top = 10.dp) - ) - } - } else { - Column( - modifier = Modifier - .padding(vertical = 70.dp, horizontal = 16.dp) - .fillMaxWidth() - .wrapContentHeight(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Image( - painter = painterResource(id = R.drawable.ic_volume_bars_orange), - contentDescription = null, - ) - Box(modifier = Modifier.padding(top = 10.dp)) { Text( - text = "You're up to date!", - fontFamily = notoserif, + text = "Follow some student publications that you are interested in.", + fontFamily = lato, fontSize = 12.sp, - fontWeight = FontWeight.Medium + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 10.dp) ) + } + } else { + Column( + modifier = Modifier + .padding(vertical = 70.dp, horizontal = 16.dp) + .fillMaxWidth() + .wrapContentHeight(), + horizontalAlignment = Alignment.CenterHorizontally + ) { Image( - painter = painterResource(R.drawable.ic_underline_up_to_date), + painter = painterResource(id = R.drawable.ic_volume_bars_orange), contentDescription = null, - modifier = Modifier - .padding(start = 1.dp, top = 16.dp) - .scale(1.05F) + ) + Column(modifier = Modifier.padding(top = 10.dp)) { + Text( + text = "You're up to date!", + fontFamily = notoserif, + fontSize = 12.sp, + fontWeight = FontWeight.Medium + ) + Image( + painter = painterResource(R.drawable.ic_underline_up_to_date), + contentDescription = null, + modifier = Modifier + .padding(start = 1.dp) + .offset(y = (-3).dp) + .scale(1.05F) + ) + } + Text( + text = "You've seen all new articles from the publications you are following.", + fontFamily = lato, + fontSize = 12.sp, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 10.dp) ) } - Text( - text = "You've seen all new articles from the publications you are following.", - fontFamily = lato, - fontSize = 12.sp, - fontWeight = FontWeight.Medium, - textAlign = TextAlign.Center, - modifier = Modifier.padding(top = 10.dp) - ) } } } - } - item { - Box { - Text( - text = "Other Articles", - fontFamily = notoserif, - fontSize = 20.sp, - fontWeight = FontWeight.Medium - ) - Image( - painter = painterResource(R.drawable.ic_underline_other_article), - contentDescription = null, - modifier = Modifier - .padding(start = 2.dp, top = 25.dp) - .scale(1.05F) - ) + item { + Column { + Text( + text = "Other Articles", + fontFamily = notoserif, + fontSize = 20.sp, + fontWeight = FontWeight.Medium + ) + Image( + painter = painterResource(R.drawable.ic_underline_other_article), + contentDescription = null, + modifier = Modifier + .padding(start = 2.dp) + .offset(y = (-7).dp) + .scale(1.05F) + ) + } } - } - item { - when (val otherArticlesState = homeUiState.otherArticlesState) { - ArticlesRetrievalState.Loading -> { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - CircularProgressIndicator(color = VolumeOrange) + item { + when (val otherArticlesState = homeUiState.otherArticlesState) { + ArticlesRetrievalState.Loading -> { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + CircularProgressIndicator(color = VolumeOrange) + } } - } - ArticlesRetrievalState.Error -> { - // TODO Prompt to try again, queryAllArticles manually (it's public). Could be that internet is down. - } - is ArticlesRetrievalState.Success -> { - Column( - modifier = Modifier - .wrapContentHeight() - .padding(end = 12.dp, top = 25.dp), - verticalArrangement = Arrangement.spacedBy(20.dp), - ) { - otherArticlesState.articles.forEach { article -> - CreateHorizontalArticleRow( - article - ) { - onArticleClick( - article, - NavigationSource.OTHER_ARTICLES - ) + ArticlesRetrievalState.Error -> { + // TODO Prompt to try again, queryAllArticles manually (it's public). Could be that internet is down. + } + is ArticlesRetrievalState.Success -> { + Column( + modifier = Modifier + .wrapContentHeight() + .padding(end = 12.dp, top = 25.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + otherArticlesState.articles.forEach { article -> + CreateArticleRow( + article + ) { + onArticleClick( + article, + NavigationSource.OTHER_ARTICLES + ) + } } } } } } } + }) + + if (FirstTimeShown.firstTimeShown) { + PermissionRequestDialog( + showBottomBar = showBottomBar, + notificationFlowStatus = homeViewModel.getNotificationPermissionFlowStatus(), + updateNotificationFlowStatus = { + homeViewModel.updateNotificationPermissionFlowStatus(it) + }) } - }) + } +} + +/** + * Keeps track of when app navigates away from HomeScreen so PermissionRequestDialog + * only occurs when the app FIRST is navigated to the HomeScreen. + */ +object FirstTimeShown { + var firstTimeShown = true } diff --git a/app/src/main/java/com/cornellappdev/volume/ui/screens/IndividualPublicationScreen.kt b/app/src/main/java/com/cornellappdev/volume/ui/screens/IndividualPublicationScreen.kt index 1c04663..825978c 100644 --- a/app/src/main/java/com/cornellappdev/volume/ui/screens/IndividualPublicationScreen.kt +++ b/app/src/main/java/com/cornellappdev/volume/ui/screens/IndividualPublicationScreen.kt @@ -11,22 +11,23 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.cornellappdev.volume.analytics.NavigationSource import com.cornellappdev.volume.data.models.Article -import com.cornellappdev.volume.ui.components.general.CreateHorizontalArticleRow +import com.cornellappdev.volume.ui.components.general.CreateArticleRow import com.cornellappdev.volume.ui.components.general.CreateIndividualPublicationHeading import com.cornellappdev.volume.ui.states.ArticlesRetrievalState import com.cornellappdev.volume.ui.states.PublicationRetrievalState -import com.cornellappdev.volume.ui.theme.GrayFour +import com.cornellappdev.volume.ui.theme.GrayThree import com.cornellappdev.volume.ui.theme.VolumeOrange import com.cornellappdev.volume.ui.viewmodels.IndividualPublicationViewModel -//"61980a202fef10d6b7f20747" @Composable -fun IndividualPublicationScreen(individualPublicationViewModel: IndividualPublicationViewModel = hiltViewModel(), onArticleClick: (Article, NavigationSource) -> Unit) { - +fun IndividualPublicationScreen( + individualPublicationViewModel: IndividualPublicationViewModel = hiltViewModel(), + onArticleClick: (Article, NavigationSource) -> Unit +) { val publicationUiState = individualPublicationViewModel.publicationUiState LazyColumn { - item{ + item { when (val publicationState = publicationUiState.publicationState) { PublicationRetrievalState.Loading -> { Column( @@ -53,10 +54,14 @@ fun IndividualPublicationScreen(individualPublicationViewModel: IndividualPublic } } } - item{ - Divider(modifier=Modifier.padding(top=20.dp, start=100.dp, end=100.dp), color = GrayFour, thickness = 1.dp) + item { + Row { + Spacer(Modifier.weight(1f, true)) + Divider(Modifier.weight(1f, true), color = GrayThree, thickness = 2.dp) + Spacer(Modifier.weight(1f, true)) + } } - item{ + item { when (val articlesByPublicationState = publicationUiState.articlesByPublicationState) { ArticlesRetrievalState.Loading -> { Column( @@ -73,13 +78,14 @@ fun IndividualPublicationScreen(individualPublicationViewModel: IndividualPublic } is ArticlesRetrievalState.Success -> { - Column(verticalArrangement = Arrangement.spacedBy(20.dp), + Column( + verticalArrangement = Arrangement.spacedBy(20.dp), modifier = Modifier .wrapContentHeight() .padding(top = 20.dp, start = 12.dp, end = 12.dp) ) { articlesByPublicationState.articles.forEach { article -> - CreateHorizontalArticleRow( + CreateArticleRow( article ) { onArticleClick( diff --git a/app/src/main/java/com/cornellappdev/volume/ui/screens/PublicationsScreen.kt b/app/src/main/java/com/cornellappdev/volume/ui/screens/PublicationsScreen.kt index 5cd3cae..46fd4d2 100644 --- a/app/src/main/java/com/cornellappdev/volume/ui/screens/PublicationsScreen.kt +++ b/app/src/main/java/com/cornellappdev/volume/ui/screens/PublicationsScreen.kt @@ -20,8 +20,8 @@ import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import com.cornellappdev.volume.R import com.cornellappdev.volume.data.models.Publication -import com.cornellappdev.volume.ui.components.general.CreateFollowPublicationRow -import com.cornellappdev.volume.ui.components.general.CreateHorizontalPublicationRowFollowing +import com.cornellappdev.volume.ui.components.general.CreatePublicationColumn +import com.cornellappdev.volume.ui.components.general.CreatePublicationRow import com.cornellappdev.volume.ui.states.PublicationsRetrievalState import com.cornellappdev.volume.ui.theme.VolumeOrange import com.cornellappdev.volume.ui.theme.notoserif @@ -61,9 +61,10 @@ fun PublicationsScreen( .padding(top = innerPadding.calculateTopPadding()), ) { item { - Box { + Column( + modifier = Modifier.padding(start = 12.dp, top = 25.dp) + ) { Text( - modifier = Modifier.padding(start = 12.dp, top = 25.dp), text = "Following", fontFamily = notoserif, fontSize = 20.sp, @@ -73,7 +74,7 @@ fun PublicationsScreen( painter = painterResource(R.drawable.ic_underline_following), contentDescription = null, modifier = Modifier - .padding(start = 12.dp, top = 50.dp) + .offset(y = (-5).dp) .scale(1.05F) ) } @@ -103,7 +104,7 @@ fun PublicationsScreen( ) { items(followingPublicationsState.publications) { publication -> - CreateFollowPublicationRow(publication) { + CreatePublicationColumn(publication) { onPublicationClick(publication) } } @@ -112,9 +113,10 @@ fun PublicationsScreen( } } item { - Box { + Column( + modifier = Modifier.padding(start = 12.dp, top = 30.dp) + ) { Text( - modifier = Modifier.padding(start = 12.dp, top = 30.dp), text = "More Publications", fontFamily = notoserif, fontSize = 20.sp, @@ -124,7 +126,8 @@ fun PublicationsScreen( painter = painterResource(R.drawable.ic_underline_more_publications), contentDescription = null, modifier = Modifier - .padding(start = 16.dp, top = 55.dp) + .padding(start = 4.dp) + .offset(y = (-5).dp) .scale(1.06F) ) } @@ -155,7 +158,7 @@ fun PublicationsScreen( .padding(start = 12.dp, end = 12.dp) ) { morePublicationsState.publications.forEach { publication -> - CreateHorizontalPublicationRowFollowing( + CreatePublicationRow( publication = publication, onPublicationClick ) { publicationFromCallback, isFollowing -> @@ -176,4 +179,4 @@ fun PublicationsScreen( } } }) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/cornellappdev/volume/ui/viewmodels/HomeViewModel.kt b/app/src/main/java/com/cornellappdev/volume/ui/viewmodels/HomeViewModel.kt index 710706f..2db253b 100644 --- a/app/src/main/java/com/cornellappdev/volume/ui/viewmodels/HomeViewModel.kt +++ b/app/src/main/java/com/cornellappdev/volume/ui/viewmodels/HomeViewModel.kt @@ -14,6 +14,7 @@ import com.cornellappdev.volume.data.repositories.UserRepository import com.cornellappdev.volume.ui.states.ArticlesRetrievalState import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import javax.inject.Inject // TODO add refreshing if user follows new publications? @@ -40,7 +41,7 @@ class HomeViewModel @Inject constructor( /** * State that holds information on whether the user has any followed articles */ - val isFollowingEmpty: Boolean = false + val isFollowingEmpty: Boolean = false, ) var homeUiState by mutableStateOf(HomeUiState()) @@ -52,6 +53,14 @@ class HomeViewModel @Inject constructor( queryTrendingArticles() } + fun getNotificationPermissionFlowStatus() = runBlocking { + return@runBlocking userPreferencesRepository.fetchNotificationFlowStatus() + } + + fun updateNotificationPermissionFlowStatus(value: Boolean) = viewModelScope.launch { + userPreferencesRepository.updateNotificationFlowStatus(value) + } + // Updates the state accordingly with the trending articles fun queryTrendingArticles(limit: Double? = NUMBER_OF_TRENDING_ARTICLES) = viewModelScope.launch { diff --git a/app/src/main/java/com/cornellappdev/volume/util/NotificationService.kt b/app/src/main/java/com/cornellappdev/volume/util/NotificationService.kt index 96e5f5d..e70025f 100644 --- a/app/src/main/java/com/cornellappdev/volume/util/NotificationService.kt +++ b/app/src/main/java/com/cornellappdev/volume/util/NotificationService.kt @@ -1,26 +1,21 @@ package com.cornellappdev.volume.util -import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent -import android.graphics.Bitmap import android.graphics.BitmapFactory import android.media.RingtoneManager -import android.util.Log import androidx.core.app.NotificationCompat -import androidx.core.app.TaskStackBuilder import androidx.core.net.toUri import com.cornellappdev.volume.MainActivity import com.cornellappdev.volume.R import com.cornellappdev.volume.navigation.Routes import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage +import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking -import java.io.InputStream -import java.net.HttpURLConnection -import java.net.URL + /** * Configures the NotificationService for Firebase Messaging @@ -33,11 +28,16 @@ class NotificationService : FirebaseMessagingService() { * as keys for the intent bundle to the MainActivity. * * @property key key name + * @see [NotificationRepo](https://github.com/cuappdev/volume-backend/blob/main/src/repos/NotificationRepo.ts) */ enum class NotificationDataKeys(val key: String) { + NOTIFICATION_TYPE("notificationType"), ARTICLE_ID("articleID"), ARTICLE_URL("articleURL"), - NOTIFICATION_TYPE("notification_type") + MAGAZINE_ID("magazineID"), + MAGAZINE_PDF("magazinePDF"), + TITLE("title"), + BODY("body") } /** @@ -47,7 +47,8 @@ class NotificationService : FirebaseMessagingService() { */ enum class NotificationType(val type: String) { WEEKLY_DEBRIEF("weekly_debrief"), - NEW_ARTICLE("new_article") + NEW_ARTICLE("new_article"), + NEW_MAGAZINE("new_magazine") } /** @@ -55,21 +56,8 @@ class NotificationService : FirebaseMessagingService() { * * @param remoteMessage Object representing the message received from Firebase Cloud Messaging. */ - override fun onMessageReceived(remoteMessage: RemoteMessage) { - // There are two types of messages data messages and notification messages. Data messages are handled - // here in onMessageReceived whether the app is in the foreground or background. Data messages are the type - // traditionally used with GCM. Notification messages are only received here in onMessageReceived when the app - // is in the foreground. When the app is in the background an automatically generated notification is displayed. - // When the user taps on the notification they are returned to the app. Messages containing both notification - // and data payloads are treated as notification messages. The Firebase console always sends notification - // messages. For more see: https://firebase.google.com/docs/cloud-messaging/concept-options - - // Check if message contains a notification payload. - remoteMessage.notification?.let { - sendNotification(it, remoteMessage.data) - Log.d(TAG, "Message Notification Body: ${it.body}") - } - } + override fun onMessageReceived(remoteMessage: RemoteMessage) = + sendNotification(remoteMessage.data) /** * Called if the FCM registration token is updated. This may occur if the security of @@ -86,114 +74,106 @@ class NotificationService : FirebaseMessagingService() { } } + /** + * Obtains a unique notification ID from the data store and stores the next integer. + */ + private fun getNextNotifId(): Int = runBlocking { + val id = userPreferencesStore.data.first().notificationId + userPreferencesStore.updateData { currentPreferences -> + currentPreferences.toBuilder().setNotificationId((id + 1) % Int.MAX_VALUE).build() + } + return@runBlocking id + } + /** * Create and show a simple notification containing the received FCM message. */ private fun sendNotification( - notification: RemoteMessage.Notification, data: MutableMap ) { - // TODO test out notifications. It leverages Navigation Deep linking, not sure if it works - // https://developer.android.com/jetpack/compose/navigation#deeplinks -// val intent = Intent(this, MainActivity::class.java) -// intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - var deepLinkIntent: Intent? = null + val notificationManager = + getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val channelId = getString((R.string.default_notification_channel_id)) // What's sent back to the MainActivity depends on the type of the notification - // received from Firebase. The type is embedded in the data sent for the notification. - when (data[NotificationDataKeys.NOTIFICATION_TYPE.key]) { + // received from Firebase. The type is embedded in the data sent for the notification from the backend. + // + // Volume leverages deep links to send users to the proper composable from the notification. New + // deep links must be added to the Android Manifest. + // See: https://developer.android.com/jetpack/compose/navigation#deeplinks + val deepLinkIntent: Intent = when (data[NotificationDataKeys.NOTIFICATION_TYPE.key]) { NotificationType.NEW_ARTICLE.type -> { - deepLinkIntent = Intent( + Intent( Intent.ACTION_VIEW, "volume://${Routes.OPEN_ARTICLE.route}/${data[NotificationDataKeys.ARTICLE_ID.key]}".toUri(), this, MainActivity::class.java ) -// ) -// intent.putExtra( -// NotificationDataKeys.NOTIFICATION_TYPE.key, -// NotificationType.NEW_ARTICLE.type -// ) -// intent.putExtra( -// NotificationDataKeys.ARTICLE_ID.key, -// data[NotificationDataKeys.ARTICLE_ID.key] -// ) -// intent.putExtra( -// NotificationDataKeys.ARTICLE_URL.key, -// data[NotificationDataKeys.ARTICLE_URL.key] -// ) } NotificationType.WEEKLY_DEBRIEF.type -> { - // We simply just need to identify the type of the notification. The - // WeeklyDebrief can be retrieved from the UserRepository#GetUser - deepLinkIntent = Intent( + Intent( Intent.ACTION_VIEW, "volume://${Routes.WEEKLY_DEBRIEF.route}".toUri(), this, MainActivity::class.java ) -// intent.putExtra( -// NotificationDataKeys.NOTIFICATION_TYPE.key, -// NotificationType.NEW_ARTICLE.type -// ) } - } - - val pendingIntent: PendingIntent? = TaskStackBuilder.create(this).run { - if (deepLinkIntent != null) { - addNextIntentWithParentStack(deepLinkIntent) + NotificationType.NEW_MAGAZINE.type -> { + Intent( + Intent.ACTION_VIEW, + "volume://${Routes.OPEN_MAGAZINE.route}/${data[NotificationDataKeys.MAGAZINE_ID.key]}".toUri(), + this, + MainActivity::class.java + ) + } + else -> { + Intent(this, MainActivity::class.java) } - getPendingIntent(0, PendingIntent.FLAG_UPDATE_CURRENT) } - val channelId = getString((R.string.default_notification_channel_id)) - val notificationBuilder = NotificationCompat.Builder(this, channelId) - .setSmallIcon(R.drawable.volume_icon) + val volumeNotification = NotificationCompat.Builder(this, channelId) + .setSmallIcon(R.drawable.ic_volume_v) .setLargeIcon( BitmapFactory.decodeResource( resources, - R.drawable.volume_icon + R.drawable.ic_volume_v ) ) - .setContentTitle(notification.title) - .setContentText(notification.body) + .setContentTitle(data[NotificationDataKeys.TITLE.key]) + .setContentText(data[NotificationDataKeys.BODY.key]) .setAutoCancel(true) .setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)) - .setContentIntent(pendingIntent) - - if (notification.imageUrl != null) { - val bitmap: Bitmap? = getBitmapFromUrl(notification.imageUrl.toString()) - notificationBuilder.setStyle( - NotificationCompat.BigPictureStyle() - .bigPicture(bitmap) + .setContentIntent( + PendingIntent.getActivity( + this, + 0, + deepLinkIntent, + PendingIntent.FLAG_IMMUTABLE + ) + ) + .setGroup(packageName) + .build() + + val summaryNotification = NotificationCompat.Builder(this, channelId) + .setContentTitle("Volume") + .setSmallIcon(R.drawable.ic_volume_v) + .setStyle( + NotificationCompat.InboxStyle() + .setSummaryText("New content on Volume!") + ) + .setGroup(packageName) + .setGroupSummary(true) + .build() + + notificationManager.apply { + notify( + getNextNotifId(), + volumeNotification + ) + notify( + -1, + summaryNotification ) - } - - val notificationManager = - getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - - // Since android Oreo notification channel is needed. - val channel = NotificationChannel( - channelId, - packageName, - NotificationManager.IMPORTANCE_DEFAULT - ) - notificationManager.createNotificationChannel(channel) - - notificationManager.notify(0 /* ID of notification */, notificationBuilder.build()) - } - - private fun getBitmapFromUrl(imageUrl: String): Bitmap? { - return try { - val url = URL(imageUrl) - val connection: HttpURLConnection = url.openConnection() as HttpURLConnection - connection.doInput = true - connection.connect() - val input: InputStream = connection.inputStream - BitmapFactory.decodeStream(input) - } catch (e: Exception) { - Log.e(TAG, "Error in getting notification image: " + e.localizedMessage) - null } } diff --git a/app/src/main/proto/user_prefs.proto b/app/src/main/proto/user_prefs.proto index 191c287..4a7c802 100644 --- a/app/src/main/proto/user_prefs.proto +++ b/app/src/main/proto/user_prefs.proto @@ -16,4 +16,9 @@ message UserPreferences { repeated string bookmarked_articles = 4; map shoutout = 5; + + int32 notification_id = 6; + + // Keeps track of when the user is first presented with the PermissionRequestDialog + bool notification_flow_completed = 7; } diff --git a/app/src/main/res/drawable/ic_instagram.xml b/app/src/main/res/drawable/ic_instagram.xml index 2121626..ff2da31 100644 --- a/app/src/main/res/drawable/ic_instagram.xml +++ b/app/src/main/res/drawable/ic_instagram.xml @@ -8,12 +8,12 @@ android:pathData="M0.848,0h12.97v13h-12.97z"/> - - + android:fillColor="#C4C4C4" /> + + diff --git a/app/src/main/res/drawable/ic_volume_v.png b/app/src/main/res/drawable/ic_volume_v.png new file mode 100644 index 0000000..a76c346 Binary files /dev/null and b/app/src/main/res/drawable/ic_volume_v.png differ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index de0be38..db0eb52 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -7,7 +7,7 @@ %1$d shout-out %1$d shout-outs - fcm_default_channel + com.cornellappdev.volume Following