diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml index 217e5c51..f8467b45 100644 --- a/.idea/kotlinc.xml +++ b/.idea/kotlinc.xml @@ -1,6 +1,6 @@ - \ No newline at end of file diff --git a/README.md b/README.md index 4788961c..f2e1e462 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Хабы 🇷🇺 | [English version 🇬🇧](https://github.com/Garneg/Hubs/blob/master/README_EN.md) +# Хабы Неофициальный мобильный клиент для Хабра. Создан для изучения Jetpack Compose и Android. ### Скриншоты diff --git a/app/build.gradle b/app/build.gradle index 123ad760..b3121b96 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -5,6 +5,12 @@ plugins { id "kotlin-kapt" } +def getDate() { + def date = new Date() + def formattedDate = date.format('HHmmddMMyy') + return formattedDate +} + android { namespace "com.garnegsoft.hubs" compileSdk 34 @@ -13,8 +19,8 @@ android { applicationId "com.garnegsoft.hubs" minSdk 23 targetSdk 34 - versionCode 12 - versionName "2.3.4" + versionCode 13 + versionName "2.4.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { @@ -26,6 +32,7 @@ android { release { minifyEnabled true proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" + } benchmark { proguardFiles "proguard-rules.pro" @@ -34,9 +41,9 @@ android { debuggable false } debug { - debuggable true - applicationIdSuffix ".debug" +// applicationIdSuffix ".debug" + versionNameSuffix "-build" + getDate() } } @@ -51,7 +58,7 @@ android { compose true } composeOptions { - kotlinCompilerExtensionVersion "1.4.7" + kotlinCompilerExtensionVersion "1.5.3" } packagingOptions { resources { @@ -64,27 +71,29 @@ kapt { } dependencies { - var m3version = "1.1.1" + var m3version = "1.1.2" implementation "androidx.compose.material3:material3:$m3version" + implementation "androidx.work:work-runtime-ktx:2.9.0" + implementation "androidx.profileinstaller:profileinstaller:1.3.1" - implementation "androidx.compose.runtime:runtime-livedata:1.5.0" + implementation "androidx.compose.runtime:runtime-livedata:1.5.4" implementation "me.saket.telephoto:zoomable-image-coil:0.4.0" implementation "org.jsoup:jsoup:1.16.1" implementation "org.jetbrains.kotlinx:kotlinx-serialization-json-jvm:1.5.1" implementation "androidx.appcompat:appcompat:1.6.1" - implementation "androidx.core:core-ktx:1.10.1" - implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.6.1" - implementation "androidx.activity:activity-compose:1.7.2" + implementation "androidx.core:core-ktx:1.12.0" + implementation "androidx.lifecycle:lifecycle-runtime-ktx:2.6.2" + implementation "androidx.activity:activity-compose:1.8.1" implementation "androidx.compose.ui:ui:$compose_ui_version" implementation "androidx.compose.ui:ui-tooling-preview:$compose_ui_version" - implementation "androidx.compose.material:material:1.5.0" + implementation "androidx.compose.material:material:1.5.4" implementation "com.squareup.okhttp3:okhttp:4.11.0" - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.1" - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.1" + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3' + implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3' implementation "com.google.accompanist:accompanist-systemuicontroller:$accompanist_version" @@ -98,14 +107,15 @@ dependencies { implementation "io.coil-kt:coil-svg:$coil_version" implementation "org.jetbrains.kotlin:kotlin-reflect:1.9.0" - implementation "androidx.browser:browser:1.6.0" - implementation "com.google.android.material:material:1.9.0" + implementation "androidx.browser:browser:1.7.0" + implementation "com.google.android.material:material:1.10.0" implementation "androidx.constraintlayout:constraintlayout:2.1.4" - implementation "androidx.core:core-ktx:1.10.1" + implementation "androidx.core:core-ktx:1.12.0" implementation "androidx.coordinatorlayout:coordinatorlayout:1.2.0" implementation "androidx.datastore:datastore-preferences:1.0.0" implementation "androidx.room:room-ktx:$room_version" + implementation "androidx.room:room-runtime:$room_version" kapt "androidx.room:room-compiler:$room_version" testImplementation "junit:junit:4.13.2" diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 9ffdfe2b..a32be191 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -46,4 +46,5 @@ } # @Serializable and @Polymorphic are used at runtime for polymorphic serialization. --keepattributes RuntimeVisibleAnnotations,AnnotationDefault \ No newline at end of file +-keepattributes RuntimeVisibleAnnotations,AnnotationDefault +-dontobfuscate \ No newline at end of file diff --git a/app/src/androidTest/java/com/garnegsoft/hubs/ExampleInstrumentedTest.kt b/app/src/androidTest/java/com/garnegsoft/hubs/ExampleInstrumentedTest.kt index 0efe9e19..1c0fc09c 100644 --- a/app/src/androidTest/java/com/garnegsoft/hubs/ExampleInstrumentedTest.kt +++ b/app/src/androidTest/java/com/garnegsoft/hubs/ExampleInstrumentedTest.kt @@ -20,5 +20,7 @@ class ExampleInstrumentedTest { // Context of the app under test. val appContext = InstrumentationRegistry.getInstrumentation().targetContext assertEquals("com.garnegsoft.hubs", appContext.packageName) + + } } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 36e7bb56..eb3bcc81 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -33,6 +33,10 @@ android:name="android.app.lib_name" android:value="" /> + + @@ -50,6 +54,17 @@ android:name=".AuthActivity" android:exported="false" android:theme="@style/Theme.Hubs.Auth" /> + + + + + \ No newline at end of file diff --git a/app/src/main/java/com/garnegsoft/hubs/MainActivity.kt b/app/src/main/java/com/garnegsoft/hubs/MainActivity.kt index cda08d2e..4d1772eb 100644 --- a/app/src/main/java/com/garnegsoft/hubs/MainActivity.kt +++ b/app/src/main/java/com/garnegsoft/hubs/MainActivity.kt @@ -1,50 +1,53 @@ package com.garnegsoft.hubs - -import android.content.Context +import android.app.ActivityManager +import android.content.Intent +import android.net.Uri import android.os.Bundle import android.util.Log import android.webkit.CookieManager import androidx.activity.ComponentActivity import androidx.activity.compose.BackHandler import androidx.activity.compose.setContent -import androidx.compose.animation.EnterExitState import androidx.compose.animation.EnterTransition -import androidx.compose.animation.ExitTransition import androidx.compose.animation.ExperimentalAnimationApi -import androidx.compose.animation.animateColor import androidx.compose.animation.core.EaseIn +import androidx.compose.animation.core.EaseInOut import androidx.compose.animation.core.EaseOut -import androidx.compose.animation.core.InternalAnimationApi import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn import androidx.compose.animation.scaleOut +import androidx.compose.animation.slideInVertically import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.core.content.pm.ShortcutInfoCompat +import androidx.core.content.pm.ShortcutManagerCompat +import androidx.core.graphics.drawable.IconCompat import androidx.core.view.WindowCompat -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.preferencesDataStore import androidx.lifecycle.coroutineScope +import androidx.navigation.NavDeepLink import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import com.garnegsoft.hubs.api.HabrApi +import com.garnegsoft.hubs.api.dataStore.AuthDataController import com.garnegsoft.hubs.api.dataStore.HubsDataStore -import com.garnegsoft.hubs.api.dataStore.settingsDataStoreFlowWithDefault +import com.garnegsoft.hubs.api.dataStore.LastReadArticleController import com.garnegsoft.hubs.api.me.Me import com.garnegsoft.hubs.api.me.MeController import com.garnegsoft.hubs.ui.screens.AboutScreen @@ -53,47 +56,51 @@ import com.garnegsoft.hubs.ui.screens.article.ArticleScreen import com.garnegsoft.hubs.ui.screens.comments.CommentsScreen import com.garnegsoft.hubs.ui.screens.comments.CommentsThreadScreen import com.garnegsoft.hubs.ui.screens.company.CompanyScreen +import com.garnegsoft.hubs.ui.screens.history.HistoryScreen import com.garnegsoft.hubs.ui.screens.hub.HubScreen -import com.garnegsoft.hubs.ui.screens.main.ArticlesScreen import com.garnegsoft.hubs.ui.screens.main.AuthorizedMenu +import com.garnegsoft.hubs.ui.screens.main.MainScreen import com.garnegsoft.hubs.ui.screens.main.UnauthorizedMenu import com.garnegsoft.hubs.ui.screens.offline.OfflineArticlesScreen import com.garnegsoft.hubs.ui.screens.search.SearchScreen import com.garnegsoft.hubs.ui.screens.settings.ArticleScreenSettingsScreen +import com.garnegsoft.hubs.ui.screens.settings.FeedSettingsScreen import com.garnegsoft.hubs.ui.screens.settings.SettingsScreen +import com.garnegsoft.hubs.ui.screens.user.LogoutConfirmDialog import com.garnegsoft.hubs.ui.screens.user.UserScreen import com.garnegsoft.hubs.ui.screens.user.UserScreenPages import com.garnegsoft.hubs.ui.theme.HubsTheme import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking // TODO: shouldn't be singleton -var cookies: String = "" +var cookies: String by mutableStateOf("") var authorized: Boolean = false -val Context.authDataStore by preferencesDataStore(HubsDataStore.Auth.DataStoreName) -val Context.settingsDataStore by preferencesDataStore(HubsDataStore.Settings.DataStoreName) -val Context.lastReadDataStore by preferencesDataStore(HubsDataStore.LastRead.DataStoreName) - class MainActivity : ComponentActivity() { - @OptIn(ExperimentalAnimationApi::class, InternalAnimationApi::class) + + + @OptIn(ExperimentalAnimationApi::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) WindowCompat.setDecorFitsSystemWindows(window, false) +// (this.getSystemService(ACTIVITY_SERVICE) as ActivityManager) +// .clearApplicationUserData() + var authStatus: Boolean? by mutableStateOf(null) - val cookiesFlow = authDataStore.data.map { it.get(HubsDataStore.Auth.Keys.Cookies) ?: "" } - val isAuthorizedFlow = - authDataStore.data.map { it[HubsDataStore.Auth.Keys.Authorized] ?: false } + val cookiesFlow = HubsDataStore.Auth.getValueFlow(this, HubsDataStore.Auth.Cookies) + val isAuthorizedFlow = HubsDataStore.Auth.getValueFlow(this, HubsDataStore.Auth.Authorized) + lifecycle.coroutineScope.launch { cookiesFlow.collect { cookies = it + } } @@ -104,409 +111,507 @@ class MainActivity : ComponentActivity() { } val authActivityLauncher = - registerForActivityResult(AuthActivityResultContract()) { result -> + registerForActivityResult(AuthActivityResultContract()) { it -> CookieManager.getInstance().removeAllCookies(null) lifecycle.coroutineScope.launch { - result?.let { res -> - authDataStore.edit { - it[HubsDataStore.Auth.Keys.Cookies] = res.split("; ") - .find { it.startsWith("connect_sid") }!! + "; hl=ru; fl=ru" - it[HubsDataStore.Auth.Keys.Authorized] = true - authorized = true - //cookies = result + it?.let { result -> + HubsDataStore.Auth.edit( + context = this@MainActivity, + pref = HubsDataStore.Auth.Cookies, + value = result.split("; ").find { it.startsWith("connect_sid") }!! + ) + + HubsDataStore.Auth.edit( + context = this@MainActivity, + pref = HubsDataStore.Auth.Authorized, + value = true + ) + authorized = true + + launch(Dispatchers.IO) { + MeController.getMe()?.let { + val shortcut = ShortcutInfoCompat.Builder(this@MainActivity, "bookmarks_shortcut") + .setIntent( + Intent(Intent.ACTION_VIEW).apply { + `package` = BuildConfig.APPLICATION_ID + data = Uri.parse("https://habr.com/users/${it.alias}/bookmarks") + } + ) + .setIcon(IconCompat.createWithResource(this@MainActivity, R.drawable.bookmarks_shortcut_icon)) + .setShortLabel("Закладки") + .setLongLabel("Закладки") + .build() + ShortcutManagerCompat.pushDynamicShortcut(this@MainActivity, shortcut) + + } + } } } } - intent.dataString?.let { Log.e("intentData", it) } HabrApi.initialize(this) + + setContent { - val themeMode by settingsDataStoreFlowWithDefault( - HubsDataStore.Settings.Keys.Theme, - HubsDataStore.Settings.Keys.ThemeModes.Undetermined.ordinal - ).collectAsState(initial = null) - val theme by remember(themeMode) { - mutableStateOf( - themeMode?.let { - HubsDataStore.Settings.Keys.ThemeModes.values()[it] - } ?: HubsDataStore.Settings.Keys.ThemeModes.Undetermined + key(cookies) { + val themeMode by HubsDataStore.Settings + .getValueFlow(this, HubsDataStore.Settings.Theme.ColorSchemeMode) + .run { HubsDataStore.Settings.Theme.ColorSchemeMode.mapValues(this) } + .collectAsState(initial = null) - ) - } - - var userInfo: Me? by remember { mutableStateOf(null) } - val userInfoUpdateBlock = remember { - { - userInfo = MeController.getMe() - Log.e("userInfoUpdateBlock", userInfo.toString()) - } - } - LaunchedEffect( - key1 = isAuthorizedFlow.collectAsState(initial = false).value, - block = { - launch(Dispatchers.IO, block = { userInfoUpdateBlock() }) - }) - if (themeMode != null && authStatus != null) { - HubsTheme( - darkTheme = when (theme) { - HubsDataStore.Settings.Keys.ThemeModes.Undetermined -> isSystemInDarkTheme() - HubsDataStore.Settings.Keys.ThemeModes.SystemDefined -> isSystemInDarkTheme() - HubsDataStore.Settings.Keys.ThemeModes.Dark -> true - else -> false + var userInfo: Me? by remember { mutableStateOf(null) } + val userInfoUpdateBlock = remember { + { + userInfo = MeController.getMe() + Log.e("userInfoUpdateBlock", userInfo.toString()) } - ) { - val navController = rememberNavController() - - NavHost( - modifier = Modifier - .statusBarsPadding() - .navigationBarsPadding(), - navController = navController, - startDestination = "articles", - builder = { - - composable( - "articles", - exitTransition = { ExitTransition.None }, - popEnterTransition = { EnterTransition.None } - ) { + } + LaunchedEffect( + key1 = isAuthorizedFlow.collectAsState(initial = false).value, + block = { + launch(Dispatchers.IO, block = { userInfoUpdateBlock() }) + }) + if (themeMode != null && authStatus != null) { + HubsTheme( + darkTheme = when (themeMode) { + HubsDataStore.Settings.Theme.ColorSchemeMode.ColorScheme.SystemDefined -> isSystemInDarkTheme() + HubsDataStore.Settings.Theme.ColorSchemeMode.ColorScheme.Undetermined -> isSystemInDarkTheme() + HubsDataStore.Settings.Theme.ColorSchemeMode.ColorScheme.Dark -> true + else -> false + } + ) { + val navController = rememberNavController() + + NavHost( + modifier = Modifier + .statusBarsPadding() + .navigationBarsPadding(), + navController = navController, + startDestination = "articles", + builder = { - ArticlesScreen( - viewModelStoreOwner = it, - onSearchClicked = { navController.navigate("search") }, - onArticleClicked = { - navController.navigate("article/$it") - }, - onCommentsClicked = { - navController.navigate("comments/$it") - }, - onUserClicked = { - navController.navigate("user/$it") - }, - onCompanyClicked = { - navController.navigate("company/$it") - }, - onHubClicked = { - navController.navigate("hub/$it") - }, - menu = { - val authorizedMenu by isAuthorizedFlow.collectAsState( - initial = false - ) - if (authorizedMenu && userInfo != null) { - AuthorizedMenu( - userAlias = userInfo!!.alias, - avatarUrl = userInfo!!.avatarUrl, - onProfileClick = { navController.navigate("user/${userInfo!!.alias}") }, - onArticlesClick = { navController.navigate("user/${userInfo!!.alias}?page=${UserScreenPages.Articles}") }, - onCommentsClick = { navController.navigate("user/${userInfo!!.alias}?page=${UserScreenPages.Comments}") }, - onBookmarksClick = { navController.navigate("user/${userInfo!!.alias}?page=${UserScreenPages.Bookmarks}") }, - onSavedArticlesClick = { navController.navigate("savedArticles") }, - onSettingsClick = { navController.navigate("settings") }, - onAboutClick = { navController.navigate("about") } - ) - } else { - UnauthorizedMenu( - onLoginClick = { - authActivityLauncher.launch(Unit) - lifecycle.coroutineScope.launch(Dispatchers.IO) { userInfoUpdateBlock() } - }, - onSavedArticlesClick = { navController.navigate("savedArticles") }, - onSettingsClick = { navController.navigate("settings") }, - onAboutClick = { navController.navigate("about") } + composable( + "articles", + + popEnterTransition = { EnterTransition.None } + ) { + + MainScreen( + viewModelStoreOwner = it, + onSearchClicked = { navController.navigate("search") }, + onArticleClicked = { + navController.navigate("article/$it") + }, + onCommentsClicked = { + navController.navigate("comments/$it") + }, + onUserClicked = { + navController.navigate("user/$it") + }, + onCompanyClicked = { + navController.navigate("company/$it") + }, + onHubClicked = { + navController.navigate("hub/$it") + }, + menu = { + val authorizedMenu by isAuthorizedFlow.collectAsState( + initial = false ) + if (authorizedMenu && userInfo != null) { + AuthorizedMenu( + userAlias = userInfo!!.alias, + avatarUrl = userInfo!!.avatarUrl, + onProfileClick = { navController.navigate("user/${userInfo!!.alias}") }, + onArticlesClick = { navController.navigate("user/${userInfo!!.alias}?page=${UserScreenPages.Articles}") }, + onCommentsClick = { navController.navigate("user/${userInfo!!.alias}?page=${UserScreenPages.Comments}") }, + onBookmarksClick = { + navController.navigate( + "user/${userInfo!!.alias}?page=${UserScreenPages.Bookmarks}" + ) + }, + onSavedArticlesClick = { + navController.navigate( + "savedArticles" + ) + }, + onHistoryClick = { navController.navigate("history")}, + onSettingsClick = { navController.navigate("settings") }, + onAboutClick = { navController.navigate("about") } + ) + } else { + UnauthorizedMenu( + onLoginClick = { + authActivityLauncher.launch(Unit) + lifecycle.coroutineScope.launch( + Dispatchers.IO + ) { userInfoUpdateBlock() } + }, + onSavedArticlesClick = { + navController.navigate( + "savedArticles" + ) + }, + onHistoryClick = { navController.navigate("history")}, + onSettingsClick = { navController.navigate("settings") }, + onAboutClick = { navController.navigate("about") } + ) + } } - } - ) - } - - composable( - route = "article/{id}?offline={offline}", - deepLinks = ArticleNavDeepLinks, - enterTransition = { - scaleIn( - tween(200, easing = EaseOut), - 0.9f - ) + fadeIn( - tween(durationMillis = 200, easing = EaseIn) ) - }, - exitTransition = { - ExitTransition.None - }, - popExitTransition = { - scaleOut( - tween(200, easing = EaseOut), - 0.9f - ) + fadeOut( - tween(200, easing = EaseOut) - ) - - }, - popEnterTransition = { - EnterTransition.None } - ) { - - val id = it.arguments?.getString("id")?.toIntOrNull() - val offline = it.arguments?.getString("offline")?.toBooleanStrict() - val clearLastArticle = remember { - { - lifecycle.coroutineScope.launch(Dispatchers.IO) { - lastReadDataStore.edit { - it[HubsDataStore.LastRead.Keys.LastArticleRead] = 0 - it[HubsDataStore.LastRead.Keys.LastArticleReadPosition] = - 0 + composable( + route = "article/{id}?offline={offline}", + deepLinks = ArticleNavDeepLinks, + enterTransition = { + scaleIn( + tween(150, easing = EaseInOut), + 0.9f + ) + fadeIn( + tween(durationMillis = 150, easing = EaseIn) + ) + slideInVertically( + tween(durationMillis = 150, easing = EaseIn), + initialOffsetY = { it / 9 } + ) + }, + popEnterTransition = { + fadeIn( + tween(durationMillis = 50, easing = EaseIn) + ) + }, + exitTransition = { + scaleOut( + tween(150, easing = EaseIn), + 0.9f + ) + fadeOut( + tween(150, easing = EaseOut) + ) + + }, + popExitTransition = { + scaleOut( + tween(150, easing = EaseOut), + 0.9f + ) + fadeOut( + tween(150, easing = EaseOut) + ) + + }, + ) { + + val id = it.arguments?.getString("id")?.toIntOrNull() + val offline = + it.arguments?.getString("offline")?.toBooleanStrict() + + val clearLastArticle = remember { + { + lifecycle.coroutineScope.launch(Dispatchers.IO) { + LastReadArticleController.clearLastArticle(this@MainActivity) } } } - } - - BackHandler(enabled = true) { - clearLastArticle() - if (this@MainActivity.intent.data != null && navController.previousBackStackEntry == null) { - this@MainActivity.finish() - } else { - navController.popBackStack() - } - } - - - ArticleScreen( - articleId = id!!, - isOffline = offline ?: false, - onBackButtonClicked = { + + BackHandler(enabled = true) { clearLastArticle() if (this@MainActivity.intent.data != null && navController.previousBackStackEntry == null) { this@MainActivity.finish() } else { navController.popBackStack() } - }, - onCommentsClicked = { - clearLastArticle() - navController.navigate("comments/${id}") - }, - onAuthorClicked = { - clearLastArticle() - navController.navigate("user/${it}") - }, - onHubClicked = { - clearLastArticle() - navController.navigate("hub/$it") - }, - onCompanyClick = { - clearLastArticle() - navController.navigate("company/$it") - }, - onViewImageRequest = { - navController.navigate(route = "imageViewer?imageUrl=$it") - }, - onArticleClick = { - navController.navigate("article/$it") - }, - viewModelStoreOwner = it - ) + } + + ArticleScreen( + articleId = id!!, + isOffline = offline ?: false, + onBackButtonClicked = { + clearLastArticle() + if (this@MainActivity.intent.data != null && navController.previousBackStackEntry == null) { + this@MainActivity.finish() + } else { + navController.popBackStack() + } + }, + onCommentsClicked = { + clearLastArticle() + navController.navigate("comments/${id}") + }, + onAuthorClicked = { + clearLastArticle() + navController.navigate("user/${it}") + }, + onHubClicked = { + clearLastArticle() + navController.navigate("hub/$it") + }, + onCompanyClick = { + clearLastArticle() + navController.navigate("company/$it") + }, + onViewImageRequest = { + navController.navigate(route = "imageViewer?imageUrl=$it") + }, + onArticleClick = { + navController.navigate("article/$it") + }, + viewModelStoreOwner = it + ) + + + } + composable(route = "search") { + SearchScreen( + viewModelStoreOwner = it, + onArticleClicked = { navController.navigate("article/$it") }, + onUserClicked = { navController.navigate("user/$it") }, + onHubClicked = { navController.navigate("hub/$it") }, + onCompanyClicked = { navController.navigate("company/$it") }, + onCommentsClicked = { navController.navigate("comments/$it") }, + onBackClicked = { navController.navigateUp() } + ) + } - } - - composable(route = "search") { - SearchScreen( - viewModelStoreOwner = it, - onArticleClicked = { navController.navigate("article/$it") }, - onUserClicked = { navController.navigate("user/$it") }, - onHubClicked = { navController.navigate("hub/$it") }, - onCompanyClicked = { navController.navigate("company/$it") }, - onCommentsClicked = { navController.navigate("comments/$it") }, - onBackClicked = { navController.navigateUp() } - ) - } - - composable(route = "settings") { - SettingsScreen( - onBack = { - navController.popBackStack() - }, - onArticleScreenSettings = { - navController.navigate("article_settings") - } - ) - } - - composable(route = "article_settings") { - ArticleScreenSettingsScreen( - onBack = { navController.popBackStack() } - ) - } - - composable( - route = "comments/{postId}?commentId={commentId}", - deepLinks = CommentsScreenNavDeepLinks - ) { - val postId = it.arguments!!.getString("postId")!! - val commentId = it.arguments?.getString("commentId") - CommentsScreen( - viewModelStoreOwner = it, - parentPostId = postId.toInt(), - commentId = commentId?.toInt(), - onBackClicked = { - if (this@MainActivity.intent.data != null && navController.previousBackStackEntry == null) { - this@MainActivity.finish() - } else { + composable(route = "settings") { + SettingsScreen( + onBack = { navController.popBackStack() + }, + onArticleScreenSettings = { + navController.navigate("article_settings") + }, + onFeedSettings = { + navController.navigate("feed_settings") } - }, - onArticleClicked = { navController.navigate("article/$postId") }, - onUserClicked = { navController.navigate("user/$it") }, - onImageClick = { navController.navigate(route = "imageViewer?imageUrl=$it") }, - onThreadClick = { navController.navigate("thread/$postId/$it") } - ) + ) + } - } - - composable("thread/{articleId}/{threadId}") { - val articleId = it.arguments!!.getString("articleId")?.toInt() - val threadId = it.arguments!!.getString("threadId")?.toInt() + composable(route = "article_settings") { + ArticleScreenSettingsScreen( + onBack = { navController.popBackStack() } + ) + } - CommentsThreadScreen( - articleId = articleId!!, - threadId = threadId!!, - onAuthor = { navController.navigate("user/$it") }, - onImageClick = { navController.navigate("image/$it") }, - onBack = { navController.popBackStack() } - ) - } - - composable( - "user/{alias}?page={page}", - deepLinks = UserScreenNavDeepLinks - ) { + composable(route = "feed_settings") { + FeedSettingsScreen( + onBack = { navController.popBackStack()} + ) + } - val page = - it.arguments?.getString("page") - ?.let { UserScreenPages.valueOf(it) } - ?: UserScreenPages.Profile - val deepLinkPage = - it.arguments?.getString("deepLinkPage")?.let { - when (it) { - "posts" -> UserScreenPages.Articles - "comments" -> UserScreenPages.Comments - "bookmarks" -> UserScreenPages.Bookmarks - - else -> null - } - } - val alias = it.arguments!!.getString("alias")!! + composable( + route = "comments/{postId}?commentId={commentId}", + deepLinks = CommentsScreenNavDeepLinks + ) { + val postId = it.arguments!!.getString("postId")!! + val commentId = it.arguments?.getString("commentId") + CommentsScreen( + viewModelStoreOwner = it, + parentPostId = postId.toInt(), + commentId = commentId?.toInt(), + onBackClicked = { + if (this@MainActivity.intent.data != null && navController.previousBackStackEntry == null) { + this@MainActivity.finish() + } else { + navController.popBackStack() + } + }, + onArticleClicked = { navController.navigate("article/$postId") }, + onUserClicked = { navController.navigate("user/$it") }, + onImageClick = { navController.navigate(route = "imageViewer?imageUrl=$it") }, + onThreadClick = { navController.navigate("thread/$postId/$it") } + ) + + } - val logoutCoroutineScope = rememberCoroutineScope() - UserScreen( - isAppUser = alias == userInfo?.alias, - initialPage = deepLinkPage ?: page, - alias = alias, - onBack = { - if (this@MainActivity.intent.data != null && navController.previousBackStackEntry == null) { - this@MainActivity.finish() - } else { - navController.popBackStack() + composable("thread/{articleId}/{threadId}") { + val articleId = + it.arguments!!.getString("articleId")?.toInt() + val threadId = it.arguments!!.getString("threadId")?.toInt() + + CommentsThreadScreen( + articleId = articleId!!, + threadId = threadId!!, + onAuthor = { navController.navigate("user/$it") }, + onImageClick = { navController.navigate("image/$it") }, + onBack = { navController.popBackStack() } + ) + } + + composable( + route = "user/{alias}?page={page}", + deepLinks = UserScreenNavDeepLinks, + popExitTransition = { fadeOut(tween(50)) }, + + ) { + + val page = + it.arguments?.getString("page") + ?.let { UserScreenPages.valueOf(it) } + ?: UserScreenPages.Profile + val deepLinkPage = + it.arguments?.getString("deepLinkPage")?.let { + when (it) { + "posts" -> UserScreenPages.Articles + "comments" -> UserScreenPages.Comments + "bookmarks" -> UserScreenPages.Bookmarks + + else -> null + } } - }, - onArticleClicked = { navController.navigate("article/$it") }, - onUserClicked = { navController.navigate("user/$it") }, - onCommentsClicked = { navController.navigate("comments/$it") }, - onCommentClicked = { postId, commentId -> - navController.navigate( - "comments/$postId?commentId=$commentId" - ) - }, - onCompanyClick = { navController.navigate("company/$it") }, - viewModelStoreOwner = it, - onLogout = { - logoutCoroutineScope.launch { - authDataStore.edit { - it[HubsDataStore.Auth.Keys.Authorized] = false - it[HubsDataStore.Auth.Keys.Cookies] = "" + val alias = it.arguments!!.getString("alias")!! + + val logoutCoroutineScope = rememberCoroutineScope() + var showLogoutConfirmationDialog by remember { mutableStateOf(false) } + LogoutConfirmDialog( + show = showLogoutConfirmationDialog, + onDismiss = { showLogoutConfirmationDialog = false }, + onProceed = { + logoutCoroutineScope.launch { + val shortcuts = ShortcutManagerCompat.getDynamicShortcuts(this@MainActivity).map { it.id } + ShortcutManagerCompat.disableShortcuts(this@MainActivity, shortcuts, "Вы вышли из приложения!") + + AuthDataController.clearAuthData(this@MainActivity) + + authorized = false + navController.popBackStack( + "articles", + inclusive = false + ) + showLogoutConfirmationDialog = false } - //cookies = "" - authorized = false - navController.popBackStack( - "articles", - inclusive = false + }) + UserScreen( + isAppUser = alias == userInfo?.alias, + initialPage = deepLinkPage ?: page, + alias = alias, + onBack = { + if (this@MainActivity.intent.data != null && navController.previousBackStackEntry == null) { + this@MainActivity.finish() + } else { + navController.popBackStack() + } + }, + onArticleClicked = { navController.navigate("article/$it") }, + onUserClicked = { navController.navigate("user/$it") }, + onCommentsClicked = { navController.navigate("comments/$it") }, + onCommentClicked = { postId, commentId -> + navController.navigate( + "comments/$postId?commentId=$commentId" ) + }, + onCompanyClick = { navController.navigate("company/$it") }, + viewModelStoreOwner = it, + onLogout = { + showLogoutConfirmationDialog = true + }, + onHubClicked = { + navController.navigate("hub/$it") } - }, - onHubClicked = { - navController.navigate("hub/$it") + ) + if (this.transition.isRunning) { + Box( + modifier = Modifier + .fillMaxSize() + .pointerInput(this.transition.isRunning) {}) } - ) - } - - composable( - "hub/{alias}", - deepLinks = HubScreenNavDeepLinks - ) { - val alias = it.arguments?.getString("alias") - HubScreen(alias = alias!!, viewModelStoreOwner = it, - onBackClick = { - if (this@MainActivity.intent.data != null && navController.previousBackStackEntry == null) { - this@MainActivity.finish() - } else { - navController.popBackStack() - } - }, - onArticleClick = { navController.navigate("article/$it") }, - onCompanyClick = { navController.navigate("company/$it") }, - onUserClick = { navController.navigate("user/$it") }, - onCommentsClick = { navController.navigate("comments/$it") } - ) - } - composable( - "company/{alias}", - deepLinks = CompanyScreenNavDeepLinks, - ) { - val alias = it.arguments?.getString("alias")!! - CompanyScreen( - viewModelStoreOwner = it, - alias = alias, - onBack = { - if (this@MainActivity.intent.data != null && navController.previousBackStackEntry == null) { - this@MainActivity.finish() - } else { - navController.popBackStack() - } - }, - onArticleClick = { navController.navigate("article/$it") }, - onCommentsClick = { navController.navigate("comments/$it") }, - onUserClick = { navController.navigate("user/$it") } - ) - } - - composable("about") { - AboutScreen { - navController.popBackStack() } - } - - composable("savedArticles") { - OfflineArticlesScreen( - onBack = { navController.popBackStack() }, - onArticleClick = { navController.navigate("article/$it?offline=true") } - ) - } - - composable("imageViewer?imageUrl={imageUrl}") { - val url = it.arguments?.getString("imageUrl") - ImageViewScreen( - model = url!!, - onBack = { navController.popBackStack() }) - } - }) - + + composable( + "hub/{alias}", + deepLinks = HubScreenNavDeepLinks, + popExitTransition = { fadeOut(tween(50)) } + ) { + val alias = it.arguments?.getString("alias") + HubScreen(alias = alias!!, viewModelStoreOwner = it, + onBackClick = { + if (this@MainActivity.intent.data != null && navController.previousBackStackEntry == null) { + this@MainActivity.finish() + } else { + navController.popBackStack() + } + }, + onArticleClick = { navController.navigate("article/$it") }, + onCompanyClick = { navController.navigate("company/$it") }, + onUserClick = { navController.navigate("user/$it") }, + onCommentsClick = { navController.navigate("comments/$it") } + ) + + if (this.transition.isRunning) { + Box( + modifier = Modifier + .fillMaxSize() + .pointerInput(this.transition.isRunning) {}) + } + } + composable( + "company/{alias}", + deepLinks = CompanyScreenNavDeepLinks, + popExitTransition = { fadeOut(tween(50)) } + ) { + val alias = it.arguments?.getString("alias")!! + CompanyScreen( + viewModelStoreOwner = it, + alias = alias, + onBack = { + if (this@MainActivity.intent.data != null && navController.previousBackStackEntry == null) { + this@MainActivity.finish() + } else { + navController.popBackStack() + } + }, + onArticleClick = { navController.navigate("article/$it") }, + onCommentsClick = { navController.navigate("comments/$it") }, + onUserClick = { navController.navigate("user/$it") } + ) + + if (this.transition.isRunning) { + Box( + modifier = Modifier + .fillMaxSize() + .pointerInput(this.transition.isRunning) {}) + } + } + + composable("about") { + AboutScreen { + navController.popBackStack() + } + } + + composable( + route = "savedArticles", + deepLinks = listOf(NavDeepLink("hubs://saved-articles")) + ) { + + OfflineArticlesScreen( + onBack = { navController.popBackStack() }, + onArticleClick = { navController.navigate("article/$it?offline=true") } + ) + } + + composable("imageViewer?imageUrl={imageUrl}") { + val url = it.arguments?.getString("imageUrl") + ImageViewScreen( + model = url!!, + onBack = { navController.popBackStack() }) + } + + composable("history"){ + HistoryScreen( + onBack = { navController.popBackStack() }, + onArticleClick = { navController.navigate("article/$it")}, + onUserClick = { navController.navigate("user/$it")}, + onHubClick = { navController.navigate("hub/$it")}, + onCompanyClick = { navController.navigate("company/$it")} + ) + } + }) + + } } } } diff --git a/app/src/main/java/com/garnegsoft/hubs/NavDeepLinks.kt b/app/src/main/java/com/garnegsoft/hubs/NavDeepLinks.kt index 4c9ed9d9..75b17b89 100644 --- a/app/src/main/java/com/garnegsoft/hubs/NavDeepLinks.kt +++ b/app/src/main/java/com/garnegsoft/hubs/NavDeepLinks.kt @@ -19,6 +19,9 @@ val UserScreenNavDeepLinks = listOf( NavDeepLink("https://habr.com/{lang}/users/{alias}/{deepLinkPage}/{subPage}"), NavDeepLink("https://habr.com/users/{alias}"), NavDeepLink("https://habr.com/users/{alias}/"), + NavDeepLink("https://habr.com/users/{alias}/{deepLinkPage}"), + NavDeepLink("https://habr.com/users/{alias}/{deepLinkPage}/") + ) val HubScreenNavDeepLinks = listOf( diff --git a/app/src/main/java/com/garnegsoft/hubs/api/BaseHabrSnippetListModel.kt b/app/src/main/java/com/garnegsoft/hubs/api/BaseHabrSnippetListModel.kt index 5122b282..78cb7348 100644 --- a/app/src/main/java/com/garnegsoft/hubs/api/BaseHabrSnippetListModel.kt +++ b/app/src/main/java/com/garnegsoft/hubs/api/BaseHabrSnippetListModel.kt @@ -11,8 +11,8 @@ import kotlinx.coroutines.launch abstract class AbstractSnippetListModel( - override val path: String, - override val baseArgs: Map, + open val path: String, + open val baseArgs: Map, initialFilter: Filter? = null, open val coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.Default), ) : HabrSnippetListModel where S : HabrSnippet { @@ -43,7 +43,7 @@ abstract class AbstractSnippetListModel( override val isRefreshing: LiveData get() = _isRefreshing private val _isLoadingNextPage = MutableLiveData(false) - val isLoadingNextPage: LiveData get() = _isLoadingNextPage + override val isLoadingNextPage: LiveData get() = _isLoadingNextPage private val _lastLoadedPage = MutableLiveData() override val lastLoadedPage: LiveData @@ -98,8 +98,6 @@ abstract class AbstractSnippetListModel( interface HabrSnippetListModel where T : HabrSnippet { - val path: String - val baseArgs: Map val data: LiveData?> val lastLoadedPage: LiveData @@ -107,6 +105,8 @@ interface HabrSnippetListModel where T : HabrSnippet { val isLoading: LiveData val isRefreshing: LiveData + + val isLoadingNextPage: LiveData fun load(args: Map): HabrList? @@ -119,4 +119,3 @@ interface HabrSnippetListModel where T : HabrSnippet { } - diff --git a/app/src/main/java/com/garnegsoft/hubs/api/Enums.kt b/app/src/main/java/com/garnegsoft/hubs/api/Enums.kt index 09614f62..98dba79e 100644 --- a/app/src/main/java/com/garnegsoft/hubs/api/Enums.kt +++ b/app/src/main/java/com/garnegsoft/hubs/api/Enums.kt @@ -11,17 +11,14 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect -import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.ExperimentalTextApi import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.withAnnotation import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.sp import com.garnegsoft.hubs.ui.theme.HubsTheme @@ -97,14 +94,14 @@ enum class PostType { } } -enum class PostComplexity { +enum class PublicationComplexity { Low, Medium, High, None; companion object{ - fun fromString(complexity: String?): PostComplexity { + fun fromString(complexity: String?): PublicationComplexity { // if (complexity == null) // return PostComplexity.None return when(complexity){ diff --git a/app/src/main/java/com/garnegsoft/hubs/api/HabrApi.kt b/app/src/main/java/com/garnegsoft/hubs/api/HabrApi.kt index c4f5a897..417de5e4 100644 --- a/app/src/main/java/com/garnegsoft/hubs/api/HabrApi.kt +++ b/app/src/main/java/com/garnegsoft/hubs/api/HabrApi.kt @@ -36,6 +36,11 @@ class HabrApi { .build() } + + // Shouldn't be used in production. Use only in tests + fun setHttpClient(client: OkHttpClient) { + HttpClient = client + } fun get(path: String, args: Map? = null, version: Int = 2): Response? { val finalArgs = mutableMapOf("hl" to "ru", "fl" to "ru") diff --git a/app/src/main/java/com/garnegsoft/hubs/api/article/Article.kt b/app/src/main/java/com/garnegsoft/hubs/api/article/Article.kt index e638e9a8..b59efa9d 100644 --- a/app/src/main/java/com/garnegsoft/hubs/api/article/Article.kt +++ b/app/src/main/java/com/garnegsoft/hubs/api/article/Article.kt @@ -2,50 +2,50 @@ package com.garnegsoft.hubs.api.article import com.garnegsoft.hubs.api.ArticleFormat import com.garnegsoft.hubs.api.EditorVersion -import com.garnegsoft.hubs.api.PostComplexity +import com.garnegsoft.hubs.api.PublicationComplexity import com.garnegsoft.hubs.api.PostType /** * Represents article */ -class Article( - - val id: Int, - - val title: String, - - val timePublished: String, - - val author: Author?, - - val contentHtml: String, - - val isCorporative: Boolean, - - val editorVersion: EditorVersion, - - val hubs: List, - - val tags: List, - - val statistics: Statistics, - - val format: ArticleFormat?, - - val postType: PostType, - - val metadata: Metadata?, - - val readingTime: Int, - - val complexity: PostComplexity, - - val relatedData: RelatedData?, - - val translationData: TranslationData, - - val polls: List +data class Article( + + val id: Int, + + val title: String, + + val timePublished: String, + + val author: Author?, + + val contentHtml: String, + + val isCorporative: Boolean, + + val editorVersion: EditorVersion, + + val hubs: List, + + val tags: List, + + val statistics: Statistics, + + val format: ArticleFormat?, + + val postType: PostType, + + val metadata: Metadata?, + + val readingTime: Int, + + val complexity: PublicationComplexity, + + val relatedData: RelatedData?, + + val translationData: TranslationData, + + val polls: List ) { diff --git a/app/src/main/java/com/garnegsoft/hubs/api/article/ArticleController.kt b/app/src/main/java/com/garnegsoft/hubs/api/article/ArticleController.kt index 65704eb2..a4463f23 100644 --- a/app/src/main/java/com/garnegsoft/hubs/api/article/ArticleController.kt +++ b/app/src/main/java/com/garnegsoft/hubs/api/article/ArticleController.kt @@ -152,7 +152,7 @@ class ArticleController { metadata = if (it.metadata != null) com.garnegsoft.hubs.api.article.Article.Metadata( it.metadata!!.mainImageUrl ) else null, - complexity = PostComplexity.fromString(it.complexity), + complexity = PublicationComplexity.fromString(it.complexity), readingTime = it.readingTime, relatedData = it.relatedData?.let { com.garnegsoft.hubs.api.article.Article.RelatedData( @@ -255,7 +255,7 @@ class ArticleController { ) }, - complexity = PostComplexity.fromString(formatted.complexity), + complexity = PublicationComplexity.fromString(formatted.complexity), readingTime = formatted.readingTime, relatedData = formatted.relatedData?.let { com.garnegsoft.hubs.api.article.Article.RelatedData( diff --git a/app/src/main/java/com/garnegsoft/hubs/api/article/list/ArticleSnippet.kt b/app/src/main/java/com/garnegsoft/hubs/api/article/list/ArticleSnippet.kt index 28c3ecb2..1fe5b320 100644 --- a/app/src/main/java/com/garnegsoft/hubs/api/article/list/ArticleSnippet.kt +++ b/app/src/main/java/com/garnegsoft/hubs/api/article/list/ArticleSnippet.kt @@ -9,92 +9,87 @@ import com.garnegsoft.hubs.api.article.Article */ @Immutable data class ArticleSnippet( - - /** + + /** * Id of the post */ override val id: Int, - - /** + + /** * Formatted time of publishing of the post */ val timePublished: String, - - /** + + /** * Is corporative post or not */ val isCorporative: Boolean, - - /** + + /** * Title of the post */ val title: String, - - /** + + /** * Version of editor used for writing the post */ val editorVersion: EditorVersion, - - /** + + /** * Type of the post */ val type: PostType, - - /** + + /** * Labels of the post (e.g. translate) */ val labels: List?, - - /** + + /** * Author of the post */ val author: Article.Author? = null, - - /** + + /** * Unformatted statistics data, better use formatted statistics field */ val statistics: Article.Statistics, - - /** + + /** * Hubs that include the post */ val hubs: List?, - - /** + + /** * Text snippet of the post, max length is 3500 characters */ val textSnippet: String, - - /** + + /** * Url of image to draw attention (a.k.a. КДПВ) */ val imageUrl: String?, - - /** - * Tags of the post - */ - val tags: List? = null, - - /** + + /** * Format of the post */ val format: ArticleFormat?, - - /** + + /** * Time to read article */ val readingTime: Int, - - /** + + /** * Complexity of the article */ - val complexity: PostComplexity, - - /** + val complexity: PublicationComplexity, + + /** * Data related to app user */ val relatedData: Article.RelatedData?, - - val isTranslation: Boolean + + val isTranslation: Boolean ) : HabrSnippet \ No newline at end of file diff --git a/app/src/main/java/com/garnegsoft/hubs/api/article/list/ArticlesListController.kt b/app/src/main/java/com/garnegsoft/hubs/api/article/list/ArticlesListController.kt index 9bc6ef2f..d76f08d4 100755 --- a/app/src/main/java/com/garnegsoft/hubs/api/article/list/ArticlesListController.kt +++ b/app/src/main/java/com/garnegsoft/hubs/api/article/list/ArticlesListController.kt @@ -36,8 +36,6 @@ class ArticlesListController { var articleIdsfinal = mutableListOf() val articlesRefs = mutableMapOf() - - articlesIds.forEach { try { articlesRefs += mapOf( @@ -140,7 +138,7 @@ class ArticlesListController { ), imageUrl = it.leadData.imageUrl, textSnippet = it.leadData.textHtml, - complexity = PostComplexity.fromString(it.complexity), + complexity = PublicationComplexity.fromString(it.complexity), readingTime = it.readingTime, relatedData = it.relatedData?.let { com.garnegsoft.hubs.api.article.Article.RelatedData( diff --git a/app/src/main/java/com/garnegsoft/hubs/api/article/offline/InArticleImageEntity.kt b/app/src/main/java/com/garnegsoft/hubs/api/article/offline/InArticleImageEntity.kt deleted file mode 100644 index 1fb25121..00000000 --- a/app/src/main/java/com/garnegsoft/hubs/api/article/offline/InArticleImageEntity.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.garnegsoft.hubs.api.article.offline - -import androidx.room.* - - -@Entity( - tableName = "in_article_images", -) -data class InArticleImageEntity( - - @ColumnInfo(typeAffinity = ColumnInfo.BLOB) - val data: ByteArray, - - @ColumnInfo(name = "original_url", index = true) - val originalUrl: String, - - @ColumnInfo(name = "parent_article_id", index = true) - val parentArticleId: Int, - - @PrimaryKey(autoGenerate = true) - val id: Long = 0, -) - -@Dao -interface InArticleImagesDao { - - @Upsert - fun upsert(entity: InArticleImageEntity) - - @Query("DELETE FROM in_article_images WHERE parent_article_id = :articleId") - fun deleteAllBelongsToArticle(articleId: Int) - - @Delete - fun delete(entity: InArticleImageEntity) - - @Query("SELECT * FROM in_article_images WHERE parent_article_id = :articleId") - fun getAllBelongsToArticle(articleId: Int): List - - @Query("SELECT * FROM in_article_images WHERE original_url = :url LIMIT 1") - fun getByUrl(url: String): InArticleImageEntity - -} - diff --git a/app/src/main/java/com/garnegsoft/hubs/api/article/offline/OfflineArticle.kt b/app/src/main/java/com/garnegsoft/hubs/api/article/offline/OfflineArticle.kt new file mode 100644 index 00000000..e9711fd9 --- /dev/null +++ b/app/src/main/java/com/garnegsoft/hubs/api/article/offline/OfflineArticle.kt @@ -0,0 +1,184 @@ +package com.garnegsoft.hubs.api.article.offline + +import android.content.Context +import androidx.room.* +import androidx.room.migration.Migration +import kotlinx.coroutines.flow.Flow + + +private const val SNIPPETS_TABLE_NAME = "offline_articles_snippets" +private const val ARTICLES_TABLE_NAME = "offline_articles" +private const val DATABASE_NAME = "offline_db" + +@Entity( + tableName = SNIPPETS_TABLE_NAME, + indices = [Index("article_id", unique = true)] +) +data class OfflineArticleSnippet( + + @ColumnInfo("article_id") + val articleId: Int, + + @ColumnInfo("author_name") + val authorName: String?, + + @ColumnInfo("author_avatar_url") + val authorAvatarUrl: String?, + + @ColumnInfo("time_published") + val timePublished: String, + + val title: String, + + @ColumnInfo("reading_time") + val readingTime: Int, + + @ColumnInfo("is_translation") + val isTranslation: Boolean, + + @ColumnInfo("text_snippet") + val textSnippet: String, + + @ColumnInfo("thumbnail_url") + val thumbnailUrl: String?, + + @TypeConverters(HubsConverter::class) + val hubs: HubsList, + + @ColumnInfo + @PrimaryKey(autoGenerate = true) + val id: Int = 0, +) + +@Entity( + tableName = ARTICLES_TABLE_NAME, +) +data class OfflineArticle( + @ColumnInfo("article_id", index = true) + val articleId: Int, + + @ColumnInfo("author_name") + val authorName: String?, + + @ColumnInfo("author_avatar_url") + val authorAvatarUrl: String?, + + @ColumnInfo("time_published") + val timePublished: String, + + val title: String, + + @ColumnInfo("reading_time") + val readingTime: Int, + + @ColumnInfo("is_translation") + val isTranslation: Boolean, + + + @ColumnInfo("content_html") + val contentHtml: String, + + @TypeConverters(HubsConverter::class) + val hubs: HubsList, + + @PrimaryKey(autoGenerate = true) + val id: Int = 0, + + ) + +@ProvidedTypeConverter +class HubsConverter { + + @TypeConverter + fun fromHubs(hubs: HubsList): String { + return hubs.hubsList.joinToString(",") + } + + @TypeConverter + fun fromString(hubs: String): HubsList { + return HubsList(hubs.split(",")) + } + + +} + +class HubsList( + val hubsList: List +) + +val Context.offlineArticlesDatabase: OfflineArticlesDatabase + get() = OfflineArticlesDatabase.getDb(this) + + +@Dao +interface OfflineArticlesDao { + + /** + * @return article entity by **article_id**, not by room table id + */ + @Query("SELECT * FROM $ARTICLES_TABLE_NAME WHERE article_id = :articleId") + fun getArticleById(articleId: Int): OfflineArticle + + @Query("SELECT EXISTS (SELECT * FROM $ARTICLES_TABLE_NAME WHERE article_id = :articleId)") + fun exists(articleId: Int): Boolean + + @Query("SELECT EXISTS (SELECT * FROM $ARTICLES_TABLE_NAME WHERE article_id = :articleId)") + fun existsFlow(articleId: Int): Flow + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertSnippet(entity: OfflineArticleSnippet) + + @Delete + fun deleteSnippet(entity: OfflineArticleSnippet) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insert(entity: OfflineArticle) + + @Query("DELETE FROM $ARTICLES_TABLE_NAME WHERE article_id = :id") + fun delete(id: Int) + + @Query("DELETE FROM $SNIPPETS_TABLE_NAME WHERE article_id = :id") + fun deleteSnippet(id: Int) + + /** + * @return list of article entities from older to newer + */ + @Query("SELECT * FROM $SNIPPETS_TABLE_NAME ORDER BY id ASC") + fun getAllSnippetsSortedByIdAsc(): Flow> + + /** + * @return list of article entities from newer to older + */ + @Query("SELECT * FROM $SNIPPETS_TABLE_NAME ORDER BY id DESC") + fun getAllSnippetsSortedByIdDesc(): Flow> + +} + +@TypeConverters(HubsConverter::class) +@Database( + entities = [OfflineArticleSnippet::class, OfflineArticle::class], + version = 2 +) +abstract class OfflineArticlesDatabase : RoomDatabase() { + + abstract fun articlesDao(): OfflineArticlesDao + + companion object { + @Volatile + private var instance: OfflineArticlesDatabase? = null + fun getDb(context: Context): OfflineArticlesDatabase { + return instance ?: synchronized(this) { + val inst = Room.databaseBuilder( + context = context, + klass = OfflineArticlesDatabase::class.java, + name = DATABASE_NAME + ) + .addTypeConverter(HubsConverter()) + .fallbackToDestructiveMigration() + .build() + instance = inst + inst + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/garnegsoft/hubs/api/article/offline/OfflineArticleSnippet.kt b/app/src/main/java/com/garnegsoft/hubs/api/article/offline/OfflineArticleSnippet.kt deleted file mode 100644 index 476623c2..00000000 --- a/app/src/main/java/com/garnegsoft/hubs/api/article/offline/OfflineArticleSnippet.kt +++ /dev/null @@ -1,179 +0,0 @@ -package com.garnegsoft.hubs.api.article.offline - -import android.content.Context -import androidx.room.* -import kotlinx.coroutines.flow.Flow - - -private const val SNIPPETS_TABLE_NAME = "offline_articles_snippets" -private const val ARTICLES_TABLE_NAME = "offline_articles" -private const val DATABASE_NAME = "offline_db" - -@Entity( - tableName = SNIPPETS_TABLE_NAME, - indices = [Index("article_id", unique = true),] -) -data class OfflineArticleSnippet( - - @ColumnInfo("article_id") - val articleId: Int, - - @ColumnInfo("author_name") - val authorName: String?, - - @ColumnInfo("author_avatar_url") - val authorAvatarUrl: String?, - - @ColumnInfo("time_published") - val timePublished: String, - - val title: String, - - @ColumnInfo("reading_time") - val readingTime: Int, - - @ColumnInfo("is_translation") - val isTranslation: Boolean, - - @ColumnInfo("text_snippet") - val textSnippet: String, - - @ColumnInfo("thumbnail_url") - val thumbnailUrl: String?, - - @TypeConverters(HubsConverter::class) - val hubs: HubsList, - - @ColumnInfo - @PrimaryKey(autoGenerate = true) - val id: Int = 0, -) - -@Entity( - tableName = ARTICLES_TABLE_NAME, -) -data class OfflineArticle( - @ColumnInfo("article_id", index = true) - val articleId: Int, - - @ColumnInfo("author_name") - val authorName: String?, - - @ColumnInfo("author_avatar_url") - val authorAvatarUrl: String?, - - @ColumnInfo("time_published") - val timePublished: String, - - val title: String, - - @ColumnInfo("reading_time") - val readingTime: Int, - - @ColumnInfo("is_translation") - val isTranslation: Boolean, - - - @ColumnInfo("content_html") - val contentHtml: String, - - @TypeConverters(HubsConverter::class) - val hubs: HubsList, - - @PrimaryKey(autoGenerate = true) - val id: Int = 0, - - ) - -@ProvidedTypeConverter -class HubsConverter { - - @TypeConverter - fun fromHubs(hubs: HubsList): String { - return hubs.hubsList.joinToString(",") - } - - @TypeConverter - fun fromString(hubs: String): HubsList { - return HubsList(hubs.split(",")) - } - - -} - -class HubsList( - val hubsList: List -) - -val Context.offlineArticlesDatabase: OfflineArticlesDatabase - get() = OfflineArticlesDatabase.getDb(this) - - -@Dao -interface OfflineArticlesDao{ - - /** - * @return article entity by **article_id**, not just by id - */ - @Query("SELECT * FROM $ARTICLES_TABLE_NAME WHERE article_id = :articleId") - suspend fun _getArticleById(articleId: Int): OfflineArticle - - @Query("SELECT EXISTS (SELECT * FROM $ARTICLES_TABLE_NAME WHERE article_id = :articleId)") - suspend fun exists(articleId: Int): Boolean - - @Query("SELECT EXISTS (SELECT * FROM $ARTICLES_TABLE_NAME WHERE article_id = :articleId)") - fun existsFlow(articleId: Int): Flow - - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insertSnippet(entity: OfflineArticleSnippet) - - @Delete - suspend fun deleteSnippet(entity: OfflineArticleSnippet) - - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insert(entity: OfflineArticle) - - @Query("DELETE FROM $ARTICLES_TABLE_NAME WHERE article_id = :id") - suspend fun delete(id: Int) - - @Query("DELETE FROM $SNIPPETS_TABLE_NAME WHERE article_id = :id") - suspend fun deleteSnippet(id: Int) - - /** - * @return list of article entities from older to newer - */ - @Query("SELECT * FROM $SNIPPETS_TABLE_NAME ORDER BY id ASC") - fun getAllSnippetsSortedByIdAsc(): Flow> - - /** - * @return list of article entities from newer to older - */ - @Query("SELECT * FROM $SNIPPETS_TABLE_NAME ORDER BY id DESC") - fun getAllSnippetsSortedByIdDesc(): Flow> - -} - -@TypeConverters(HubsConverter::class) -@Database(entities = [OfflineArticleSnippet::class, InArticleImageEntity::class, OfflineArticle::class], version = 1) -abstract class OfflineArticlesDatabase : RoomDatabase() { - - abstract fun articlesDao(): OfflineArticlesDao - abstract fun imagesDao(): InArticleImagesDao - - companion object { - @Volatile - private var instance: OfflineArticlesDatabase? = null - fun getDb(context: Context): OfflineArticlesDatabase { - return instance ?: synchronized(this){ - val inst = Room.databaseBuilder( - context = context, - klass = OfflineArticlesDatabase::class.java, - name = DATABASE_NAME - ) - .addTypeConverter(HubsConverter()).build() - instance = inst - inst - } - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/garnegsoft/hubs/api/article/offline/OfflineArticlesController.kt b/app/src/main/java/com/garnegsoft/hubs/api/article/offline/OfflineArticlesController.kt index db35ab39..d8d7cf45 100644 --- a/app/src/main/java/com/garnegsoft/hubs/api/article/offline/OfflineArticlesController.kt +++ b/app/src/main/java/com/garnegsoft/hubs/api/article/offline/OfflineArticlesController.kt @@ -1,7 +1,28 @@ package com.garnegsoft.hubs.api.article.offline import ArticleController +import android.app.Notification import android.content.Context +import android.os.Build +import android.os.Looper +import android.widget.Toast +import androidx.work.CoroutineWorker +import androidx.work.Data +import androidx.work.ForegroundInfo +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import com.garnegsoft.hubs.R +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import org.jsoup.Jsoup +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import java.io.File class OfflineArticlesController { @@ -10,19 +31,26 @@ class OfflineArticlesController { private var _dao: OfflineArticlesDao? = null private fun getDao(context: Context): OfflineArticlesDao { - if (_dao == null){ - synchronized(this){ + + if (_dao == null) { + synchronized(this) { _dao = OfflineArticlesDatabase.getDb(context).articlesDao() return _dao!! } } else { return _dao!! - } } - suspend fun downloadArticle(articleId: Int, context: Context): Boolean { + fun downloadArticle(articleId: Int, context: Context) { + val request = OneTimeWorkRequestBuilder() + .setInputData(Data.Builder().putInt("ARTICLE_ID", articleId).build()) + .build() + WorkManager.getInstance(context).enqueue(request) + } + + private fun _downloadArticle(articleId: Int, context: Context): Boolean { val dao = getDao(context) val article = ArticleController.getOfflineArticle(articleId) @@ -30,19 +58,215 @@ class OfflineArticlesController { if (article == null || articleSnippet == null) { return false } - dao.insert(article) - dao.insertSnippet(articleSnippet) + + val newArticle = Downloader.downloadArticleResources(article, context) + val newArticleSnippet = Downloader.downloadArticleSnippetResources(articleSnippet, context) + + dao.insert(newArticle) + dao.insertSnippet(newArticleSnippet) return true } - suspend fun deleteArticle(articleId: Int, context: Context): Boolean { + fun deleteArticle(articleId: Int, context: Context): Boolean { + val request = OneTimeWorkRequestBuilder() + .setInputData(Data.Builder().putInt("ARTICLE_ID", articleId).build()) + .build() + WorkManager.getInstance(context).enqueue(request) + + return false + } + + private fun _deleteArticle(articleId: Int, context: Context): Boolean { + + val dir = File(context.filesDir, "offline_resources") + if (!dir.exists()) + return false + + val thisArticleDir = File(dir, articleId.toString()) + + if (!thisArticleDir.exists() || !thisArticleDir.deleteRecursively()) + return false + val dao = getDao(context) - if (dao.exists(articleId)){ + if (dao.exists(articleId)) { dao.delete(articleId) dao.deleteSnippet(articleId) return true } return false } + + class DownloadOfflineArticleResourcesWorker( + appContext: Context, + params: WorkerParameters + ) : CoroutineWorker(appContext, params) { + override suspend fun getForegroundInfo(): ForegroundInfo { + val notification = if (Build.VERSION.SDK_INT > 26) { + Notification.Builder(applicationContext, "download_article_worker_channel") + .setSmallIcon(R.drawable.download).build() + } else { + Notification() + } + return ForegroundInfo(0, notification) + + } + override suspend fun doWork(): Result { + setForeground(getForegroundInfo()) + val articleId = inputData.getInt("ARTICLE_ID", -1) + if (articleId < 1) { + Looper.prepare() + Toast.makeText( + applicationContext, + "Invalid article id was passed to the worker!", + Toast.LENGTH_SHORT + ).show() + return Result.failure() + } + withContext(Dispatchers.IO) { + _downloadArticle(articleId, applicationContext) + Looper.prepare() + Toast.makeText( + applicationContext, + "Статья скачана!", + Toast.LENGTH_SHORT + ).show() + } + return Result.success() + } + + } + + + class DeleteOfflineArticleResourcesWorker( + appContext: Context, + params: WorkerParameters + ) : CoroutineWorker(appContext, params) { + override suspend fun getForegroundInfo(): ForegroundInfo { + val notification = if (Build.VERSION.SDK_INT > 26) { + Notification.Builder(applicationContext, "delete_article_worker_channel") + .setSmallIcon(R.drawable.offline).build() + } else { + Notification() + } + return ForegroundInfo(0, notification) + + } + override suspend fun doWork(): Result { + setForeground(getForegroundInfo()) + val articleId = inputData.getInt("ARTICLE_ID", -1) + if (articleId < 1) { + Looper.prepare() + Toast.makeText( + applicationContext, + "Invalid article id was passed to the worker!", + Toast.LENGTH_SHORT + ).show() + return Result.failure() + } + withContext(Dispatchers.IO) { + _deleteArticle(articleId, applicationContext) + Looper.prepare() + Toast.makeText( + applicationContext, + "Статья удалена из сохраненных!", + Toast.LENGTH_SHORT + ).show() + } + return Result.success() + } + + } + + object Downloader { + val client: OkHttpClient = OkHttpClient() + + fun downloadArticleResources(article: OfflineArticle, context: Context): OfflineArticle { + var articleResult = article + var urls = mutableListOf() + + var imageCounter = 0 + val result = Jsoup.parse(article.contentHtml).forEachNode { + if(it is Element && it.tagName() == "img"){ + var url = if (it.hasAttr("data-src")){ + it.attr("data-src") + } else { + it.attr("src") + } + + urls.add(url) + it.attr("data-src", "offline-article:${article.articleId}/img$imageCounter.${url.split(".").last()}") + + imageCounter++ + } + + } as Document + + downloadImages(urls, article.articleId, context) + + article.authorAvatarUrl?.let { url -> + val request = Request.Builder().get().url(url).build() + client.newCall(request).execute().body?.let { + val dir = File(context.filesDir, "offline_resources") + if (!dir.exists()) + dir.mkdir() + val thisArticleDir = File(dir, article.articleId.toString()) + val filename = "authorAvatar.png" + val avatarImageFile = File(thisArticleDir, filename) + avatarImageFile.writeBytes(it.bytes()) + } + articleResult = articleResult.copy(authorAvatarUrl = "offline-article:${article.articleId}/authorAvatar.png") + } + + return articleResult.copy(contentHtml = result.body().html()) + } + + private fun downloadImages(urls: Collection, articleId: Int, context: Context) { + val dir = File(context.filesDir, "offline_resources") + if (!dir.exists()) + dir.mkdir() + val thisArticleDir = File(dir, articleId.toString()) + thisArticleDir.mkdir() + runBlocking(Dispatchers.IO) { + urls.forEachIndexed { index, url -> + launch { + val request = Request.Builder().get().url(url).build() + val bytes = client.newCall(request).execute().body?.bytes() + bytes?.let { + val resourceFile = + File(thisArticleDir, "img$index.${url.split(".").last()}") + resourceFile.createNewFile() + resourceFile.writeBytes(it) + } + } + } + } + } + + // Downloads only thumbnail image because avatar of user should be downloaded by downloadArticleResources method + fun downloadArticleSnippetResources(snippet: OfflineArticleSnippet, context: Context): OfflineArticleSnippet { + var articleSnippet = snippet + snippet.thumbnailUrl?.let { url -> + val request = Request.Builder().get().url(url).build() + client.newCall(request).execute().body?.let { + val dir = File(context.filesDir, "offline_resources") + if (!dir.exists()) + dir.mkdir() + val thisArticleDir = File(dir, snippet.articleId.toString()) + if (!thisArticleDir.exists()) + thisArticleDir.mkdir() + val filename = "thumbnail.${url.split(".").last()}" + val thumbnailImageFile = File(thisArticleDir, filename) + thumbnailImageFile.createNewFile() + thumbnailImageFile.writeBytes(it.bytes()) + articleSnippet = articleSnippet.copy(thumbnailUrl = "offline-article:${snippet.articleId}/$filename") + } + + } + articleSnippet = articleSnippet.copy(authorAvatarUrl = "offline-article:${snippet.articleId}/authorAvatar.png") + return articleSnippet + } + } } + + } \ No newline at end of file diff --git a/app/src/main/java/com/garnegsoft/hubs/api/dataStore/AuthDataController.kt b/app/src/main/java/com/garnegsoft/hubs/api/dataStore/AuthDataController.kt new file mode 100644 index 00000000..4f55538a --- /dev/null +++ b/app/src/main/java/com/garnegsoft/hubs/api/dataStore/AuthDataController.kt @@ -0,0 +1,28 @@ +package com.garnegsoft.hubs.api.dataStore + +import android.content.Context +import kotlinx.coroutines.flow.Flow + +class AuthDataController { + companion object { + suspend fun clearAuthData(context: Context) { + HubsDataStore.Auth.edit( + context = context, + pref = HubsDataStore.Auth.Authorized, + value = false + ) + HubsDataStore.Auth.edit( + context = context, + pref = HubsDataStore.Auth.Cookies, + value = "" + ) + } + + fun isAuthorizedFlow(context: Context): Flow { + return HubsDataStore.Auth.getValueFlow( + context = context, + pref = HubsDataStore.Auth.Authorized) + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/garnegsoft/hubs/api/dataStore/DataStoreMethods.kt b/app/src/main/java/com/garnegsoft/hubs/api/dataStore/DataStoreMethods.kt deleted file mode 100644 index 8846dfb3..00000000 --- a/app/src/main/java/com/garnegsoft/hubs/api/dataStore/DataStoreMethods.kt +++ /dev/null @@ -1,29 +0,0 @@ -package com.garnegsoft.hubs.api.dataStore - -import android.content.Context -import androidx.datastore.preferences.core.Preferences -import com.garnegsoft.hubs.authDataStore -import com.garnegsoft.hubs.lastReadDataStore -import com.garnegsoft.hubs.settingsDataStore -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map - -fun Context.lastReadDataStoreFlow(key: Preferences.Key): Flow { - return lastReadDataStore.data.map { it[key] } -} - -fun Context.settingsDataStoreFlow(key: Preferences.Key): Flow { - return settingsDataStore.data.map { it[key] } -} - -fun Context.settingsDataStoreFlowWithDefault( - key: Preferences.Key, - defaultValue: T -): Flow { - return settingsDataStore.data.map { it[key] ?: defaultValue } -} - -fun Context.authDataStoreFlowWithDefault(key: Preferences.Key, defaultValue: T): Flow { - return authDataStore.data.map { it[key] ?: defaultValue } -} - diff --git a/app/src/main/java/com/garnegsoft/hubs/api/dataStore/HubsDataStore.kt b/app/src/main/java/com/garnegsoft/hubs/api/dataStore/HubsDataStore.kt index 914a3cc6..6f62c70b 100644 --- a/app/src/main/java/com/garnegsoft/hubs/api/dataStore/HubsDataStore.kt +++ b/app/src/main/java/com/garnegsoft/hubs/api/dataStore/HubsDataStore.kt @@ -1,61 +1,139 @@ package com.garnegsoft.hubs.api.dataStore +import android.content.Context +import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.floatPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map object HubsDataStore { - object Settings { - const val DataStoreName = "settings" - object Keys { - /** - * Theme of app. - * - 0 - undetermined - * - 1 - light - * - 2 - dark - * - 3 - defined by system - */ - val Theme = intPreferencesKey("theme_mode") - enum class ThemeModes { - Undetermined, - Light, - Dark, - SystemDefined - } - object ArticleScreen { - val FontSize = floatPreferencesKey("article_font_size") - val LineHeightFactor = floatPreferencesKey("line_height_factor") - val TextWrapMode = intPreferencesKey("article_text_wrap") - val Indent = intPreferencesKey("article_indent") - } - object Comments { - val CommentsDisplayMode = intPreferencesKey("comment_display_mode") - enum class CommentsDisplayModes { - Default, - Threads, - } - } + object Settings : SingleDataStore(name = "settings") { + object Theme { + /** + * Theme of app. + * - 0 - undetermined + * - 1 - light + * - 2 - dark + * - 3 - defined by system + * + * You also have **mapValues** method for mapping int to enum values + */ + object ColorSchemeMode : DataStorePreference { + override val key = intPreferencesKey("theme_mode") + override val defaultValue = ColorScheme.SystemDefined.ordinal + + fun mapValues(flow: Flow): Flow { + return flow.map { ColorScheme.values()[it] } + } + + enum class ColorScheme { + Undetermined, + Light, + Dark, + SystemDefined + } + } + + } + + object ArticleScreen { + val FontSize = DataStorePreference.FloatPreference("article_font_size", 16f) +// val LineHeightFactor = floatPreferencesKey("line_height_factor") +// val TextWrapMode = intPreferencesKey("article_text_wrap") +// val Indent = intPreferencesKey("article_indent") + } + + object ArticleCard { + val TextSnippetFontSize = DataStorePreference.FloatPreference("article_card_snippet_font_size", 16f) + val ShowTextSnippet = DataStorePreference.BooleanPreference("article_card_show_snippet", true) + val ShowImage = DataStorePreference.BooleanPreference("article_card_show_image", true) + val TitleFontSize = DataStorePreference.FloatPreference("article_card_title_font_size", 20f) + val TextSnippetMaxLines = DataStorePreference.IntPreference("article_card_snippet_max_lines", 4) + } + + object CommentsDisplayMode : DataStorePreference { + + override val key = intPreferencesKey("comment_display_mode") + + override val defaultValue = CommentsDisplayModes.Default.ordinal + + enum class CommentsDisplayModes { + Default, + Threads, + } + } + } + + object Auth : SingleDataStore(name = "auth") { + val Authorized = DataStorePreference.BooleanPreference("authorized", false) + val Cookies = DataStorePreference.StringPreference("cookies", "") + } + + object LastRead : SingleDataStore(name = "last_read") { + + val LastArticleRead = DataStorePreference.IntPreference("last_article", 0) + //val LastArticleReadPosition = intPreferencesKey("last_article_position") + + } + +} +interface DataStorePreference { + val key: Preferences.Key + val defaultValue: T + + class FloatPreference( + name: String, + override val defaultValue: Float + ) : DataStorePreference { + override val key = floatPreferencesKey(name) + } + + class BooleanPreference( + name: String, + override val defaultValue: Boolean + ) : DataStorePreference { + override val key = booleanPreferencesKey(name) + } + + class StringPreference( + name: String, + override val defaultValue: String + ) : DataStorePreference { + override val key = stringPreferencesKey(name) + } + + class IntPreference( + name: String, + override val defaultValue: Int + ) : DataStorePreference { + override val key = intPreferencesKey(name) + } + +} - } - } - object Auth { - const val DataStoreName = "auth" - object Keys { - val Authorized = booleanPreferencesKey("authorized") - val Cookies = stringPreferencesKey("cookies") - } +abstract class SingleDataStore( + private val name: String +) { + + private val Context.store by preferencesDataStore(name) + + fun getValueFlow( + context: Context, + pref: DataStorePreference, + defaultValue: T = pref.defaultValue + ): Flow { + return context.store.data.map { it.get(pref.key) ?: defaultValue } + } + + suspend fun edit(context: Context, pref: DataStorePreference, value: T) { + context.store.edit { it.set(pref.key, value) } + } +} - } - - object LastRead { - const val DataStoreName = "last_read" - object Keys{ - val LastArticleRead = intPreferencesKey("last_article") - val LastArticleReadPosition = intPreferencesKey("last_article_position") - } - } - -} \ No newline at end of file diff --git a/app/src/main/java/com/garnegsoft/hubs/api/dataStore/LastReadArticleController.kt b/app/src/main/java/com/garnegsoft/hubs/api/dataStore/LastReadArticleController.kt new file mode 100644 index 00000000..160cac6c --- /dev/null +++ b/app/src/main/java/com/garnegsoft/hubs/api/dataStore/LastReadArticleController.kt @@ -0,0 +1,30 @@ +package com.garnegsoft.hubs.api.dataStore + +import android.content.Context +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.Flow + +class LastReadArticleController { + companion object { + suspend fun clearLastArticle(context: Context) { + HubsDataStore.LastRead.edit( + context, + HubsDataStore.LastRead.LastArticleRead, + 0 + ) + } + + suspend fun setLastArticle(context: Context, articleId: Int) { + HubsDataStore.LastRead.edit( + context, + HubsDataStore.LastRead.LastArticleRead, + articleId + ) + } + + fun getLastArticleFlow(context: Context): Flow { + return HubsDataStore.LastRead.getValueFlow(context, HubsDataStore.LastRead.LastArticleRead) + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/garnegsoft/hubs/api/history/HistoryController.kt b/app/src/main/java/com/garnegsoft/hubs/api/history/HistoryController.kt new file mode 100644 index 00000000..7e37d589 --- /dev/null +++ b/app/src/main/java/com/garnegsoft/hubs/api/history/HistoryController.kt @@ -0,0 +1,22 @@ +package com.garnegsoft.hubs.api.history + +import ArticleController +import android.content.Context + +class HistoryController { + companion object { + fun insertArticle(articleId: Int, context: Context) { + val dao = HistoryDatabase.getDb(context).dao() + dao.getEventsPaged(0, 1).firstOrNull()?.let { + if (it.actionType == HistoryActionType.Article && it.getArticle().articleId == articleId) + return + } + + ArticleController.getSnippet(articleId)?.let { + val data = HistoryArticle(articleId, it.title, it.author?.alias ?: "", it.author?.avatarUrl ?: "", it.imageUrl) + dao.insertEvent(data.toHistoryEntity()) + + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/garnegsoft/hubs/api/history/HistoryEntity.kt b/app/src/main/java/com/garnegsoft/hubs/api/history/HistoryEntity.kt new file mode 100644 index 00000000..d45e8c8b --- /dev/null +++ b/app/src/main/java/com/garnegsoft/hubs/api/history/HistoryEntity.kt @@ -0,0 +1,170 @@ +package com.garnegsoft.hubs.api.history + +import android.content.Context +import androidx.room.ColumnInfo +import androidx.room.Dao +import androidx.room.Database +import androidx.room.Delete +import androidx.room.Entity +import androidx.room.Ignore +import androidx.room.PrimaryKey +import androidx.room.Query +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.room.Upsert +import com.garnegsoft.hubs.api.HabrSnippet +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.util.Calendar +import kotlin.math.ceil + +@Entity(tableName = "history") +data class HistoryEntity( + + /** + * Data in json about the event depending on type. + */ + @ColumnInfo("data") + val data: String, + + @ColumnInfo("action_type") + val actionType: HistoryActionType, + + @ColumnInfo("timestamp") + val timestamp: Long, + + @PrimaryKey(autoGenerate = true) + override val id: Int = 0 +) : HabrSnippet + +enum class HistoryActionType { + Undefined, + Article, + UserProfile, + HubProfile, + CompanyProfile, + Comments +} + + +@Dao +interface HistoryDao { + + @Upsert + fun insertEvent(event: HistoryEntity): Long + + @Query("SELECT * FROM history ORDER BY timestamp DESC LIMIT :eventsPerPage OFFSET :pageIndex * :eventsPerPage") + fun getEventsPaged(pageIndex: Int, eventsPerPage: Int = 20): List + + @Delete + fun deleteEvent(event: HistoryEntity) + + @Query("DELETE FROM history") + fun clearAll() + + @Query("SELECT COUNT(id) FROM history") + fun eventsCount(): Int + + fun pagesCount(eventsPerPage: Int = 20): Int { + return ceil(eventsCount().toFloat() / eventsPerPage).toInt() + } +} + +@Serializable +sealed class HistoryType() { + @get:Ignore + abstract val actionType: HistoryActionType + fun toHistoryEntity(timestamp: Long = Calendar.getInstance().time.time) : HistoryEntity { + val data = Json.encodeToString(this) + return HistoryEntity(data, actionType, timestamp) + } +} + +@Serializable +data class HistoryArticle( + val articleId: Int, + val title: String, + val authorAlias: String, + val authorAvatarUrl: String, + val thumbnailUrl: String?, +) : HistoryType() { + override val actionType: HistoryActionType = HistoryActionType.Article +} + +@Serializable +data class HistoryUser( + val alias: String, + val avatarUrl: String, +) : HistoryType() { + override val actionType = HistoryActionType.UserProfile +} + +@Serializable +data class HistoryHub( + val alias: String, + val avatarUrl: String, +) : HistoryType() { + override val actionType = HistoryActionType.HubProfile +} + +@Serializable +data class HistoryCompany( + val alias: String, + val avatarUrl: String?, +) : HistoryType() { + override val actionType = HistoryActionType.CompanyProfile +} + +@Serializable +data class HistoryComments( + val parentArticle: HistoryArticle, +) : HistoryType() { + override val actionType = HistoryActionType.Comments +} + + +private val json = Json { ignoreUnknownKeys = true } + +fun HistoryEntity.getArticle() : HistoryArticle { + return json.decodeFromString(data) +} + +fun HistoryEntity.getUser() : HistoryUser { + return json.decodeFromString(data) +} + +fun HistoryEntity.getHub() : HistoryHub { + return json.decodeFromString(data) +} + +fun HistoryEntity.getCompany() : HistoryCompany { + return json.decodeFromString(data) +} + +fun HistoryEntity.getComments() : HistoryComments { + return json.decodeFromString(data) +} + + +@Database(entities = [HistoryEntity::class], version = 1) +abstract class HistoryDatabase : RoomDatabase() { + + abstract fun dao(): HistoryDao + + companion object { + @Volatile + private var instance: HistoryDatabase? = null + fun getDb(context: Context): HistoryDatabase { + return instance ?: synchronized(this){ + val inst = Room.databaseBuilder( + context = context, + klass = HistoryDatabase::class.java, + name = "history" + ).build() + instance = inst + inst + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/garnegsoft/hubs/api/history/HistoryEntityListModel.kt b/app/src/main/java/com/garnegsoft/hubs/api/history/HistoryEntityListModel.kt new file mode 100644 index 00000000..01abdf00 --- /dev/null +++ b/app/src/main/java/com/garnegsoft/hubs/api/history/HistoryEntityListModel.kt @@ -0,0 +1,88 @@ +package com.garnegsoft.hubs.api.history + +import android.content.Context +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import com.garnegsoft.hubs.api.HabrList +import com.garnegsoft.hubs.api.article.HabrSnippetListModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +class HistoryEntityListModel( + val coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.Default), + val dao: HistoryDao +) : HabrSnippetListModel { + + private val _data = MutableLiveData?>() + + override val data: LiveData?> + get() = _data + + private val _isLoading = MutableLiveData(false) + override val isLoading: LiveData + get() = _isLoading + + private val _isRefreshing = MutableLiveData(false) + override val isRefreshing: LiveData get() = _isRefreshing + + private val _isLoadingNextPage = MutableLiveData(false) + override val isLoadingNextPage: LiveData get() = _isLoadingNextPage + + override fun load(args: Map): HabrList = + HabrList(dao.getEventsPaged(pageNumber), pagesCount = dao.pagesCount()) + + + private val _lastLoadedPage = MutableLiveData() + override val lastLoadedPage: LiveData + get() = _lastLoadedPage + + private var pageNumber = 0 + + private fun _load(): HabrList? { + _isLoading.postValue(true) + val result = load(emptyMap()) + _isLoading.postValue(false) + return result + } + + override fun refresh() { + coroutineScope.launch(Dispatchers.IO) { + _isRefreshing.postValue(true) + pageNumber = 0 + _lastLoadedPage.postValue(pageNumber) + _data.postValue(_load()) + _isRefreshing.postValue(false) + } + } + + override fun loadNextPage() { + coroutineScope.launch(Dispatchers.IO) { + if (_data.value!!.pagesCount > pageNumber) { + _isLoadingNextPage.postValue(true) + pageNumber++ + var doRetry = true + while (doRetry) { + _load()?.let { nextPage -> + _data.value?.let { + delay(1000) + _data.postValue(it + nextPage) + doRetry = false + _lastLoadedPage.postValue(pageNumber) + } + } ?: delay(500) + } + _isLoadingNextPage.postValue(false) + } + } + } + + override fun loadFirstPage() { + coroutineScope.launch(Dispatchers.IO) { + _data.postValue(_load()) + _lastLoadedPage.postValue(0) + } + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/garnegsoft/hubs/api/history/search/SearchHistoryEntity.kt b/app/src/main/java/com/garnegsoft/hubs/api/history/search/SearchHistoryEntity.kt new file mode 100644 index 00000000..45431446 --- /dev/null +++ b/app/src/main/java/com/garnegsoft/hubs/api/history/search/SearchHistoryEntity.kt @@ -0,0 +1,5 @@ +package com.garnegsoft.hubs.api.history.search + + +class SearchHistoryEntity { +} \ No newline at end of file diff --git a/app/src/main/java/com/garnegsoft/hubs/api/hub/HubController.kt b/app/src/main/java/com/garnegsoft/hubs/api/hub/HubController.kt index 4d9e0668..cebd12b3 100644 --- a/app/src/main/java/com/garnegsoft/hubs/api/hub/HubController.kt +++ b/app/src/main/java/com/garnegsoft/hubs/api/hub/HubController.kt @@ -30,7 +30,7 @@ class HubController { return get(path = "hubs/$alias/profile") } - fun get(path: String, args: Map? = null): Hub? { + private fun get(path: String, args: Map? = null): Hub? { var raw = getProfile(path, args) var result: Hub? = null diff --git a/app/src/main/java/com/garnegsoft/hubs/api/user/UserController.kt b/app/src/main/java/com/garnegsoft/hubs/api/user/UserController.kt index bf7ce264..8f2c1bd7 100644 --- a/app/src/main/java/com/garnegsoft/hubs/api/user/UserController.kt +++ b/app/src/main/java/com/garnegsoft/hubs/api/user/UserController.kt @@ -10,271 +10,283 @@ import kotlinx.serialization.json.* import org.jsoup.Jsoup class UserController { - - companion object { - private fun _get(path: String, args: Map? = null): UserProfileData? { - val response = HabrApi.get(path, args) - - var result: UserProfileData? = null - - if (response?.code != 200) - return null - - response.body?.let { - var customJson = Json { ignoreUnknownKeys = true } - - result = - customJson.decodeFromJsonElement(customJson.parseToJsonElement(it.string())) - - result?.let { - if (it.avatarUrl != null) { - it.avatarUrl = "https:" + it.avatarUrl!!.replace("habrastorage", "hsto") - } - else { - it.avatarUrl = placeholderAvatarUrl(it.alias) - } - it.registerDateTime = formatTime(it.registerDateTime).split(' ') - .run { "${this[0]} ${this[1]} ${this[2]}" } - - if (it.birthday != null) - it.birthday = formatBirthdate(it.birthday!!) - - it.lastActivityDateTime = - it.lastActivityDateTime?.let { it1 -> formatTime(it1) } - } - result - } - - return result - } - - private fun _whoIs(path: String, args: Map? = null): WhoIs? { - val response = HabrApi.get(path, args) - val responseBody = response?.body?.string() - if (response?.code != 200 || responseBody == null){ - return null - } - responseBody.let { - val result = HabrDataParser.parseJson(it) - result.invitedBy?.let { - result.invitedBy!!.timeCreated = formatTime(it.timeCreated) - } - - return result - } - return null - } - - private fun _note(path: String, args: Map? = null): Note? { - val response = HabrApi.get(path, args) - - if (response?.code != 200) - return null - - response.body?.string()?.let { - val result = HabrDataParser.parseJson(it) - result.text?.let { - result.text = Jsoup.parse(it).text() - } - return result - } - return null - } - - fun get( - alias: String, - args: Map? = null, - ): User? { - val raw = _get("users/$alias/card", args) - - var result: User? = null - - raw?.let { - - result = User( - alias = it.alias, - fullname = if (it.fullname?.isEmpty() == true) null else it.fullname, - avatarUrl = it.avatarUrl, - speciality = if (it.speciality?.isEmpty() == true) null else it.speciality, - rating = it.rating, - ratingPosition = it.ratingPos, - score = it.scoreStats.score, - followersCount = it.followStats.followersCount, - subscriptionsCount = it.followStats.followCount, - isReadonly = it.isReadonly, - registrationDate = it.registerDateTime, - lastActivityDate = it.lastActivityDateTime, - birthday = it.birthday, - canBeInvited = it.canBeInvited, - location = it.location?.let { - val buffer = StringBuilder() - it.city?.title?.let { - buffer.append("$it, ") - } - it.region?.title?.let { - //buffer.append("$it, ") - } - it.country?.title?.let{ - buffer.append(it) - } - - buffer.toString() - }, - articlesCount = it.counterStats.postCount, - commentsCount = it.counterStats.commentCount, - bookmarksCount = it.counterStats.favoriteCount, - workPlaces = it.workplace.map { User.WorkPlace(it.title, it.alias) }, - relatedData = it.relatedData?.let{ User.RelatedData(it.isSubscribed)} - ) - } - - return result - } - - fun whoIs( - alias: String, - args: Map? = null, - ): User.WhoIs? { - val raw = _whoIs("users/$alias/whois", args) - - raw?.let { - return User.WhoIs( - aboutHtml = it.aboutHtml, - badges = it.badgets.map { User.WhoIs.Badge(it.title, it.description) }, - invite = it.invitedBy?.let { User.WhoIs.Invite(it.issuerLogin, it.timeCreated) }, - contacts = it.contacts.map { User.WhoIs.Contact(it.title, it.url, it.favicon) } - ) - } - return null - } - - fun note( - alias: String, - args: Map? = null - ): User.Note? { - val raw = _note("users/$alias/note", args) - - raw?.let { - return User.Note(it.text) - } - return null - } - - /** - * Subscribe/unsubscribe to user. - * @return subscription status - * @throws UnsupportedOperationException - */ - fun subscription(alias: String): Boolean { - val response = HabrApi.post("users/$alias/following/toggle") - - response.body?.string()?.let { - return Json.parseToJsonElement(it).jsonObject["isSubscribed"]?.jsonPrimitive!!.boolean - } - throw UnsupportedOperationException("User is not authorized") - - } - } - - - @Serializable - data class Note( - var text: String? - ) - - @Serializable - data class WhoIs( - var alias: String, - var badgets: List, - var aboutHtml: String, - var contacts: List, - var invitedBy: InvitedBy? = null - ) - - @Serializable - data class Badget( - var title: String, - var description: String, + + companion object { + private fun _get(path: String, args: Map? = null): UserProfileData? { + val response = HabrApi.get(path, args) + + var result: UserProfileData? = null + + if (response?.code != 200) + return null + + response.body?.let { + var customJson = Json { ignoreUnknownKeys = true } + + result = + customJson.decodeFromJsonElement(customJson.parseToJsonElement(it.string())) + + result?.let { + if (it.avatarUrl != null) { + it.avatarUrl = "https:" + it.avatarUrl!!.replace("habrastorage", "hsto") + } else { + it.avatarUrl = placeholderAvatarUrl(it.alias) + } + it.registerDateTime = formatTime(it.registerDateTime).split(' ') + .run { "${this[0]} ${this[1]} ${this[2]}" } + + if (it.birthday != null) + it.birthday = formatBirthdate(it.birthday!!) + + it.lastActivityDateTime = + it.lastActivityDateTime?.let { it1 -> formatTime(it1) } + } + result + } + + return result + } + + private fun _whoIs(path: String, args: Map? = null): WhoIs? { + val response = HabrApi.get(path, args) + val responseBody = response?.body?.string() + if (response?.code != 200 || responseBody == null) { + return null + } + responseBody.let { + val result = HabrDataParser.parseJson(it) + result.invitedBy?.let { + result.invitedBy!!.timeCreated = formatTime(it.timeCreated) + } + + + return result + } + } + + private fun _note(path: String, args: Map? = null): Note? { + val response = HabrApi.get(path, args) + + if (response?.code != 200) + return null + + response.body?.string()?.let { + val result = HabrDataParser.parseJson(it) + result.text?.let { + result.text = Jsoup.parse(it).text() + } + return result + } + return null + } + + fun get( + alias: String, + args: Map? = null, + ): User? { + val raw = _get("users/$alias/card", args) + + var result: User? = null + + raw?.let { + + result = User( + alias = it.alias, + fullname = if (it.fullname?.isEmpty() == true) null else it.fullname, + avatarUrl = it.avatarUrl, + speciality = if (it.speciality?.isEmpty() == true) null else it.speciality, + rating = it.rating, + ratingPosition = it.ratingPos, + score = it.scoreStats.score, + followersCount = it.followStats.followersCount, + subscriptionsCount = it.followStats.followCount, + isReadonly = it.isReadonly, + registrationDate = it.registerDateTime, + lastActivityDate = it.lastActivityDateTime, + birthday = it.birthday, + canBeInvited = it.canBeInvited, + location = it.location?.let { + val buffer = StringBuilder() + it.city?.title?.let { + buffer.append("$it, ") + } + it.region?.title?.let { + //buffer.append("$it, ") + } + it.country?.title?.let { + buffer.append(it) + } + + buffer.toString() + }, + articlesCount = it.counterStats.postCount, + commentsCount = it.counterStats.commentCount, + bookmarksCount = it.counterStats.favoriteCount, + workPlaces = it.workplace.map { User.WorkPlace(it.title, it.alias) }, + relatedData = it.relatedData?.let { User.RelatedData(it.isSubscribed) } + ) + } + + return result + } + + fun whoIs( + alias: String, + args: Map? = null, + ): User.WhoIs? { + val raw = _whoIs("users/$alias/whois", args) + + raw?.let { + return User.WhoIs( + aboutHtml = it.aboutHtml, + badges = it.badgets.map { User.WhoIs.Badge(it.title, it.description) }, + invite = it.invitedBy?.let { + User.WhoIs.Invite( + it.issuerLogin, + it.timeCreated + ) + }, + contacts = it.contacts.map { + var title = it.title + if (it.title == "Сайт" || it.title == "Site") { + title += " • ${it.siteTitle}" + } else { + title += " • ${it.value}" + } + + User.WhoIs.Contact(title, it.url, it.favicon) + } + ) + } + return null + } + + fun note( + alias: String, + args: Map? = null + ): User.Note? { + val raw = _note("users/$alias/note", args) + + raw?.let { + return User.Note(it.text) + } + return null + } + + /** + * Subscribe/unsubscribe to user. + * @return subscription status + * @throws UnsupportedOperationException + */ + fun subscription(alias: String): Boolean { + val response = HabrApi.post("users/$alias/following/toggle") + + response.body?.string()?.let { + return Json.parseToJsonElement(it).jsonObject["isSubscribed"]?.jsonPrimitive!!.boolean + } + throw UnsupportedOperationException("User is not authorized") + + } + } + + @Serializable + data class Note( + var text: String? + ) + + @Serializable + data class WhoIs( + var alias: String, + var badgets: List, + var aboutHtml: String, + var contacts: List, + var invitedBy: InvitedBy? = null + ) + + @Serializable + data class Badget( + var title: String, + var description: String, // var url: String? = null, // var isRemovable: Boolean - ) - - @Serializable - data class Contact( - val title: String, - val url: String, - val value: String?, - val siteTitle: String? = null, - val favicon: String? = null - ) - - @Serializable - data class InvitedBy( - val issuerLogin: String?, - var timeCreated: String - ) - + ) + + @Serializable + data class Contact( + val title: String, + val url: String, + val value: String?, + val siteTitle: String? = null, + val favicon: String? = null + ) + + @Serializable + data class InvitedBy( + val issuerLogin: String?, + var timeCreated: String + ) + } @Serializable data class UserProfileData( - var alias: String, - var fullname: String?, - var avatarUrl: String? = null, - var speciality: String?, - var gender: String, - var rating: Float, - var ratingPos: Int? = null, - var scoreStats: ScoreStats, - var relatedData: RelatedData? = null, - var followStats: FollowStats, - var lastActivityDateTime: String?, - var registerDateTime: String, - var birthday: String? = null, - var location: Location?, - var workplace: List, - var counterStats: CounterStats, - var isReadonly: Boolean, - var canBeInvited: Boolean + var alias: String, + var fullname: String?, + var avatarUrl: String? = null, + var speciality: String?, + var gender: String, + var rating: Float, + var ratingPos: Int? = null, + var scoreStats: ScoreStats, + var relatedData: RelatedData? = null, + var followStats: FollowStats, + var lastActivityDateTime: String?, + var registerDateTime: String, + var birthday: String? = null, + var location: Location?, + var workplace: List, + var counterStats: CounterStats, + var isReadonly: Boolean, + var canBeInvited: Boolean ) { - @Serializable - data class RelatedData(var isSubscribed: Boolean) + @Serializable + data class RelatedData(var isSubscribed: Boolean) } @Serializable data class CounterStats( - var postCount: Int, - var commentCount: Int, - var favoriteCount: Int + var postCount: Int, + var commentCount: Int, + var favoriteCount: Int ) @Serializable data class FollowStats( - val followCount: Int, - val followersCount: Int + val followCount: Int, + val followersCount: Int ) @Serializable data class Location( - val city: LocationItem?, - val region: LocationItem?, - val country: LocationItem? + val city: LocationItem?, + val region: LocationItem?, + val country: LocationItem? ) @Serializable data class LocationItem( - val id: String, - val title: String + val id: String, + val title: String ) @Serializable data class ScoreStats( - val score: Int, - val votesCount: Int + val score: Int, + val votesCount: Int ) @Serializable data class WorkPlace( - val title: String, - val alias: String + val title: String, + val alias: String ) diff --git a/app/src/main/java/com/garnegsoft/hubs/api/utils/dateParsing.kt b/app/src/main/java/com/garnegsoft/hubs/api/utils/dateParsing.kt index dd7d5df1..0428ba63 100644 --- a/app/src/main/java/com/garnegsoft/hubs/api/utils/dateParsing.kt +++ b/app/src/main/java/com/garnegsoft/hubs/api/utils/dateParsing.kt @@ -38,7 +38,7 @@ fun formatTime(time: String): String { var result = String() val localedTime = defaultInputFormatter.parse(time.split('+')[0].replace('T', ' '))!! - localedTime.time = localedTime.time + TimeZone.getDefault().rawOffset + TimeZone.getDefault().dstSavings + localedTime.time = localedTime.time + TimeZone.getDefault().getOffset(Date().time) var todayCallendar = Calendar.getInstance() var publishCalendar = Calendar.getInstance() @@ -94,4 +94,9 @@ fun formatFoundationDate(day: String?, month: String?, year: String?): String? { result += it.toInt().toString() } return result.ifEmpty { null } +} + +fun formatTimestamp(timestamp: Long): String { + val date = Date(timestamp) + return customOutputFormatter.format(date) } \ No newline at end of file diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/common/AsyncGifImage.kt b/app/src/main/java/com/garnegsoft/hubs/ui/common/AsyncGifImage.kt index e09bf1f6..e20f866e 100644 --- a/app/src/main/java/com/garnegsoft/hubs/ui/common/AsyncGifImage.kt +++ b/app/src/main/java/com/garnegsoft/hubs/ui/common/AsyncGifImage.kt @@ -1,6 +1,7 @@ package com.garnegsoft.hubs.api import android.os.Build +import android.util.Log import androidx.compose.foundation.Image import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable @@ -8,13 +9,70 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import coil.ImageLoader -import coil.compose.AsyncImage import coil.compose.AsyncImagePainter import coil.compose.rememberAsyncImagePainter import coil.decode.GifDecoder import coil.decode.ImageDecoderDecoder import coil.request.ImageRequest import coil.size.Size +import okhttp3.OkHttpClient +import android.content.Context +import android.graphics.BitmapFactory +import android.net.Uri +import androidx.core.graphics.drawable.toDrawable +import coil.decode.DataSource +import coil.fetch.DrawableResult +import coil.fetch.FetchResult +import coil.fetch.Fetcher +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import java.io.File + + +private var loader: ImageLoader? = null +var offlineResourcesDir: File? = null +private val Context.CommonImageLoader: ImageLoader + get() { + if (loader == null) { + loader = ImageLoader.Builder(this) + .components { + if (Build.VERSION.SDK_INT >= 28) { + add(ImageDecoderDecoder.Factory()) + } else { + add(GifDecoder.Factory()) + } + } + + .okHttpClient { + OkHttpClient.Builder() + .addInterceptor { + val urlString = it.request().url.toString() + if (it.request().url.toString().startsWith("offline-article")){ + val fileUri = urlString.split(":").last() + if (offlineResourcesDir == null){ + offlineResourcesDir = File(filesDir, "offline_resources") + } + val file = File(offlineResourcesDir, fileUri) + + if (file.exists()){ + file.readBytes().let { + return@addInterceptor Response.Builder().body(it.toResponseBody("image/${fileUri.split(".").last()}".toMediaType())).build() + } + } + return@addInterceptor Response.Builder().build() + } else { + it.proceed(it.request()) + } + }.build() + } + .crossfade(true) + .build() + } + return loader!! + } + + @Composable fun AsyncGifImage( @@ -25,28 +83,44 @@ fun AsyncGifImage( onState: (AsyncImagePainter.State) -> Unit = {} ) { val context = LocalContext.current - val imageLoader = ImageLoader.Builder(context) - .components { - if (Build.VERSION.SDK_INT >= 28) { - add(ImageDecoderDecoder.Factory()) - } else { - add(GifDecoder.Factory()) - } - } - .crossfade(true) - .build() + Image( painter = rememberAsyncImagePainter( ImageRequest.Builder(context) .data(model) .size(Size.ORIGINAL) - + .fetcherFactory( + Fetcher.Factory { data, options, imageLoader -> + CommonImageRequestFetcher(data, context) + } + ) .build(), - imageLoader = imageLoader, + imageLoader = context.CommonImageLoader, onState = onState ), contentDescription = contentDescription, modifier = modifier.fillMaxWidth(), contentScale = contentScale, ) +} + +class CommonImageRequestFetcher(val data: Any, val context: Context) : Fetcher { + override suspend fun fetch(): FetchResult? { + if (data is String || data is Uri){ + val url = data.toString() + val fileUri = url.split(":").last() + if (offlineResourcesDir == null){ + offlineResourcesDir = File(context.filesDir, "offline_resources") + } + val file = File(offlineResourcesDir, fileUri) + + if (file.exists()){ + file.readBytes().let { + val image = BitmapFactory.decodeByteArray(it, 0, it.size).toDrawable(context.resources) + return DrawableResult(image, false, DataSource.DISK) + } + } + } + return null + } } \ No newline at end of file diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/common/AsyncSvgImage.kt b/app/src/main/java/com/garnegsoft/hubs/ui/common/AsyncSvgImage.kt index a43e7a4d..bf4ab289 100644 --- a/app/src/main/java/com/garnegsoft/hubs/ui/common/AsyncSvgImage.kt +++ b/app/src/main/java/com/garnegsoft/hubs/ui/common/AsyncSvgImage.kt @@ -1,14 +1,30 @@ package com.garnegsoft.hubs.ui.common +import android.graphics.BitmapFactory +import android.net.Uri import androidx.compose.material.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.* import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.core.graphics.drawable.toDrawable +import androidx.work.WorkManager import coil.compose.AsyncImage +import coil.decode.DataSource +import coil.decode.ImageSource import coil.decode.SvgDecoder +import coil.fetch.DrawableResult +import coil.fetch.FetchResult +import coil.fetch.Fetcher +import coil.fetch.SourceResult import coil.request.ImageRequest +import com.garnegsoft.hubs.api.CommonImageRequestFetcher +import com.garnegsoft.hubs.api.offlineResourcesDir +import okio.BufferedSource +import okio.Path +import okio.Path.Companion.toPath +import java.io.File private val colorMatrix = floatArrayOf( -0.7f, 0f, 0f, 0f, 255f, @@ -25,12 +41,34 @@ fun AsyncSvgImage( revertColorsOnDarkTheme: Boolean = true, contentDescription: String? = null ) { + val context = LocalContext.current AsyncImage( modifier = modifier, model = ImageRequest.Builder(context) .data(data) .decoderFactory(SvgDecoder.Factory()) + .fetcherFactory( + Fetcher.Factory { factoryData, _, _ -> + object : Fetcher { + override suspend fun fetch(): FetchResult? { + if (factoryData is String || factoryData is Uri){ + val url = factoryData.toString() + val fileUri = url.split(":").last() + if (offlineResourcesDir == null){ + offlineResourcesDir = File(context.filesDir, "offline_resources") + } + val file = File(offlineResourcesDir, fileUri) + + if (file.exists()){ + return SourceResult(ImageSource(file.absolutePath.toPath()), "image/svg", DataSource.DISK) + } + } + return null + } + } + } + ) .build(), contentDescription = contentDescription, colorFilter = if (MaterialTheme.colors.isLight || !revertColorsOnDarkTheme) null else ColorFilter diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/common/HabrScrollableTabRow.kt b/app/src/main/java/com/garnegsoft/hubs/ui/common/HabrScrollableTabRow.kt index d0843e16..7af6fd30 100644 --- a/app/src/main/java/com/garnegsoft/hubs/ui/common/HabrScrollableTabRow.kt +++ b/app/src/main/java/com/garnegsoft/hubs/ui/common/HabrScrollableTabRow.kt @@ -22,73 +22,75 @@ import kotlin.math.absoluteValue @OptIn(ExperimentalFoundationApi::class) @Composable fun HabrScrollableTabRow( - pagerState: PagerState, - tabs: List, - onCurrentPositionTabClick: suspend CoroutineScope.(index: Int, title: String) -> Unit = { i, t -> } + pagerState: PagerState, + tabs: List, + onCurrentPositionTabClick: suspend CoroutineScope.(index: Int, title: String) -> Unit = { i, t -> } ) { - val pagerStateCoroutineScope = rememberCoroutineScope() - - ScrollableTabRow( - selectedTabIndex = pagerState.currentPage, - edgePadding = 8.dp, - divider = { - Divider() - }, - backgroundColor = MaterialTheme.colors.surface, - indicator = { - TabRowDefaults.Indicator( - modifier = Modifier.customTabIndicatorOffset( - currentTabPosition = it[pagerState.currentPage], - offset = pagerState.currentPageOffsetFraction, - nextTabPosition = - when { - pagerState.currentPageOffsetFraction < 0 -> it[pagerState.currentPage - 1] - pagerState.currentPageOffsetFraction > 0 -> it[pagerState.currentPage + 1] - else -> it[pagerState.currentPage] - } - - ) - .padding(horizontal = 8.dp) - .clip(CircleShape) - ) - }, - contentColor = if (MaterialTheme.colors.isLight) MaterialTheme.colors.secondary else MaterialTheme.colors.primary - ) { - tabs.forEachIndexed { index, title -> - Tab( - selected = index == pagerState.currentPage, - onClick = { - pagerStateCoroutineScope.launch { - if (pagerState.currentPage == index) - onCurrentPositionTabClick(this, index, title) - else - pagerState.animateScrollToPage(index) - - } - }, - ) { - Text( - title, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), - fontWeight = FontWeight.W500, - ) - } - } - } + val pagerStateCoroutineScope = rememberCoroutineScope() + + ScrollableTabRow( + modifier = Modifier.fillMaxWidth(), + selectedTabIndex = pagerState.currentPage, + edgePadding = 8.dp, + divider = { + Divider() + }, + backgroundColor = MaterialTheme.colors.surface, + indicator = { + TabRowDefaults.Indicator( + modifier = Modifier + .customTabIndicatorOffset( + currentTabPosition = it[pagerState.currentPage], + offset = pagerState.currentPageOffsetFraction, + nextTabPosition = + when { + pagerState.currentPageOffsetFraction < 0 -> it[pagerState.currentPage - 1] + pagerState.currentPageOffsetFraction > 0 -> it[pagerState.currentPage + 1] + else -> it[pagerState.currentPage] + } + + ) + .padding(horizontal = 8.dp) + .clip(CircleShape) + ) + }, + contentColor = if (MaterialTheme.colors.isLight) MaterialTheme.colors.secondary else MaterialTheme.colors.primary + ) { + tabs.forEachIndexed { index, title -> + Tab( + selected = index == pagerState.currentPage, + onClick = { + pagerStateCoroutineScope.launch { + if (pagerState.currentPage == index) + onCurrentPositionTabClick(this, index, title) + else + pagerState.animateScrollToPage(index) + + } + }, + ) { + Text( + title, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), + fontWeight = FontWeight.W500, + ) + } + } + } } fun Modifier.customTabIndicatorOffset( - currentTabPosition: TabPosition, - offset: Float, - nextTabPosition: TabPosition, + currentTabPosition: TabPosition, + offset: Float, + nextTabPosition: TabPosition, ): Modifier { - val indicatorWidth = - currentTabPosition.width + (nextTabPosition.width - currentTabPosition.width) * offset.absoluteValue - val indicatorOffset = - currentTabPosition.left + (nextTabPosition.left - currentTabPosition.left) * offset.absoluteValue - - return fillMaxWidth() - .wrapContentSize(Alignment.BottomStart) - .offset(x = indicatorOffset) - .width(indicatorWidth) + val indicatorWidth = + currentTabPosition.width + (nextTabPosition.width - currentTabPosition.width) * offset.absoluteValue + val indicatorOffset = + currentTabPosition.left + (nextTabPosition.left - currentTabPosition.left) * offset.absoluteValue + + return fillMaxWidth() + .wrapContentSize(Alignment.BottomStart) + .offset(x = indicatorOffset) + .width(indicatorWidth) } diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/screens/user/HubChip.kt b/app/src/main/java/com/garnegsoft/hubs/ui/common/HubChip.kt similarity index 96% rename from app/src/main/java/com/garnegsoft/hubs/ui/screens/user/HubChip.kt rename to app/src/main/java/com/garnegsoft/hubs/ui/common/HubChip.kt index b525c83f..4f4a83da 100644 --- a/app/src/main/java/com/garnegsoft/hubs/ui/screens/user/HubChip.kt +++ b/app/src/main/java/com/garnegsoft/hubs/ui/common/HubChip.kt @@ -1,4 +1,4 @@ -package com.garnegsoft.hubs.ui.screens.user +package com.garnegsoft.hubs.ui.common import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -60,6 +60,7 @@ fun HubChip( @Composable fun HubChip( title: String, + isSubscribed: Boolean = false, onClick: () -> Unit ) { Text( diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/common/LazyHabrSnippetsColumn.kt b/app/src/main/java/com/garnegsoft/hubs/ui/common/LazyHabrSnippetsColumn.kt index 9e74ecbf..48f80df0 100644 --- a/app/src/main/java/com/garnegsoft/hubs/ui/common/LazyHabrSnippetsColumn.kt +++ b/app/src/main/java/com/garnegsoft/hubs/ui/common/LazyHabrSnippetsColumn.kt @@ -1,14 +1,11 @@ package com.garnegsoft.hubs.ui.common import android.annotation.SuppressLint -import android.util.Log import androidx.compose.animation.core.AnimationState -import androidx.compose.animation.core.animate import androidx.compose.animation.core.animateDecay import androidx.compose.animation.splineBasedDecay import androidx.compose.foundation.gestures.FlingBehavior import androidx.compose.foundation.gestures.ScrollScope -import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState @@ -23,10 +20,6 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp import com.garnegsoft.hubs.api.HabrList import com.garnegsoft.hubs.api.HabrSnippet -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlin.math.abs @Composable fun LazyHabrSnippetsColumn( @@ -43,12 +36,70 @@ fun LazyHabrSnippetsColumn( } }, snippet: @Composable (T) -> Unit, +) { + BaseHubsLazyColumn( + data = data, + onScrollEnd = onScrollEnd, + lazyList = { + val density = LocalDensity.current + LazyColumn( + modifier = modifier, + state = it, + flingBehavior = object : FlingBehavior { + override suspend fun ScrollScope.performFling(initialVelocity: Float): Float { + var lastValue = 0f + var lastVelocity = 0f + AnimationState( + initialValue = 0f, + initialVelocity = initialVelocity * 1.3f + ).animateDecay(splineBasedDecay(density)) { + val delta = value - lastValue + val consumed = scrollBy(delta) + lastValue = value + lastVelocity = velocity + + if (consumed == 0f) + cancelAnimation() + } + + return lastVelocity + } + }, + contentPadding = contentPadding, + verticalArrangement = verticalArrangement, + horizontalAlignment = horizontalAlignment + ) { + items( + items = data.list, + key = { it.id }, + ) { + snippet(it) + } + nextPageLoadingIndicator?.let { + item { + nextPageLoadingIndicator() + } + } + } + }, + lazyListState = lazyListState) + +} + +// Most simple lazy column that notifies about scroll end +@Composable +fun BaseHubsLazyColumn( + data: HabrList, + onScrollEnd: () -> Unit, + lazyList: @Composable (state: LazyListState) -> Unit, + lazyListState: LazyListState, ) { val derivedItemsCount by remember { derivedStateOf { lazyListState.layoutInfo.totalItemsCount } } val isLastDerived by remember { derivedStateOf { - if (data.list.size > 0 && lazyListState.layoutInfo.totalItemsCount > 0) - lazyListState.layoutInfo.totalItemsCount - 1 == lazyListState.layoutInfo.visibleItemsInfo.last().index + // will work only if controller loads more than 8 snippets per page + if (data.list.size > 8 && lazyListState.layoutInfo.totalItemsCount > 8) + lazyListState.layoutInfo.totalItemsCount - 7 <= lazyListState.layoutInfo.visibleItemsInfo.last().index else false } @@ -64,49 +115,9 @@ fun LazyHabrSnippetsColumn( doPageLoad = true } - val density = LocalDensity.current - LazyColumn( - modifier = modifier, - state = lazyListState, - flingBehavior = object : FlingBehavior { - override suspend fun ScrollScope.performFling(initialVelocity: Float): Float { - var lastValue = 0f - var lastVelocity = 0f - AnimationState( - initialValue = 0f, - initialVelocity = initialVelocity * 1.3f - ).animateDecay(splineBasedDecay(density)) { - val delta = value - lastValue - val consumed = scrollBy(delta) - lastValue = value - lastVelocity = velocity - - if (consumed == 0f) - cancelAnimation() - } - - return lastVelocity - } - }, - contentPadding = contentPadding, - verticalArrangement = verticalArrangement, - horizontalAlignment = horizontalAlignment - ) { - items( - items = data.list, - key = { it.id }, - ) { - snippet(it) - } - nextPageLoadingIndicator?.let { - item { - nextPageLoadingIndicator() - } - } - } + lazyList(lazyListState) } - @Composable @SuppressLint("ModifierParameter") fun PagedHabrSnippetsColumn( @@ -127,7 +138,6 @@ fun PagedHabrSnippetsColumn( }, snippet: @Composable (T) -> Unit, ) { - val scrollEndCoroutineScope = rememberCoroutineScope() LazyHabrSnippetsColumn( data = data, modifier = modifier, diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/common/ScrollUpMethods.kt b/app/src/main/java/com/garnegsoft/hubs/ui/common/ScrollUpMethods.kt index c4707c20..9fc8ca42 100644 --- a/app/src/main/java/com/garnegsoft/hubs/ui/common/ScrollUpMethods.kt +++ b/app/src/main/java/com/garnegsoft/hubs/ui/common/ScrollUpMethods.kt @@ -31,10 +31,12 @@ import org.jsoup.select.Elements sealed interface ScrollUpMethods { companion object { suspend fun scrollLazyList(lazyListState: LazyListState){ - lazyListState.scrollToItem( - 0, - lazyListState.firstVisibleItemScrollOffset - ) + if (lazyListState.firstVisibleItemIndex > 3) { + lazyListState.scrollToItem( + 2, + lazyListState.firstVisibleItemScrollOffset + ) + } lazyListState.animateScrollToItem(0) } diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/common/TitledColumn.kt b/app/src/main/java/com/garnegsoft/hubs/ui/common/TitledColumn.kt index 571ed111..174d2a9a 100644 --- a/app/src/main/java/com/garnegsoft/hubs/ui/common/TitledColumn.kt +++ b/app/src/main/java/com/garnegsoft/hubs/ui/common/TitledColumn.kt @@ -7,6 +7,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp @@ -39,6 +40,7 @@ fun TitledColumn( modifier: Modifier = Modifier, horizontalAlignment: Alignment.Horizontal = Alignment.Start, verticalArrangement: Arrangement.Vertical = Arrangement.Top, + spaceAfterTitle: Dp = 6.dp, titleStyle: TextStyle = MaterialTheme.typography.subtitle2.copy(color = MaterialTheme.colors.onSurface), content: @Composable ColumnScope.() -> Unit ) { @@ -47,7 +49,7 @@ fun TitledColumn( horizontalAlignment = horizontalAlignment, verticalArrangement = verticalArrangement, title = { Text(text = title, style = titleStyle) }, - divider = { Spacer(modifier = Modifier.height(6.dp)) }, + divider = { Spacer(modifier = Modifier.height(spaceAfterTitle)) }, content = content ) } diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/common/ArticlesUI.kt b/app/src/main/java/com/garnegsoft/hubs/ui/common/feedCards/article/ArticleCard.kt similarity index 66% rename from app/src/main/java/com/garnegsoft/hubs/ui/common/ArticlesUI.kt rename to app/src/main/java/com/garnegsoft/hubs/ui/common/feedCards/article/ArticleCard.kt index 08ff7df1..4d79c620 100644 --- a/app/src/main/java/com/garnegsoft/hubs/ui/common/ArticlesUI.kt +++ b/app/src/main/java/com/garnegsoft/hubs/ui/common/feedCards/article/ArticleCard.kt @@ -1,146 +1,44 @@ -package com.garnegsoft.hubs.ui.common +package com.garnegsoft.hubs.ui.common.feedCards.article import ArticleController -import android.widget.Toast import androidx.compose.foundation.* import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* -import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.* 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 -import androidx.compose.ui.graphics.Shape import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.Dp +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil.compose.AsyncImage import coil.request.ImageRequest import com.garnegsoft.hubs.R -import com.garnegsoft.hubs.api.PostComplexity +import com.garnegsoft.hubs.api.PublicationComplexity import com.garnegsoft.hubs.api.PostType import com.garnegsoft.hubs.api.article.list.ArticleSnippet import com.garnegsoft.hubs.api.article.offline.OfflineArticlesController -import com.garnegsoft.hubs.api.article.offline.OfflineArticlesDao -import com.garnegsoft.hubs.api.article.offline.OfflineArticlesDatabase import com.garnegsoft.hubs.api.utils.formatLongNumbers -import com.garnegsoft.hubs.api.utils.placeholderColorLegacy -import com.garnegsoft.hubs.ui.theme.RatingNegative -import com.garnegsoft.hubs.ui.theme.RatingPositive -import com.garnegsoft.hubs.ui.theme.SecondaryColor -import kotlinx.coroutines.CoroutineScope +import com.garnegsoft.hubs.ui.theme.HubSubscribedColor +import com.garnegsoft.hubs.ui.theme.RatingNegativeColor +import com.garnegsoft.hubs.ui.theme.RatingPositiveColor import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.future.future import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext - -/** - * Style of the [ArticleCard] - */ -@Immutable -data class ArticleCardStyle( - val innerPadding: Dp = 16.dp, - val innerElementsShape: Shape = RoundedCornerShape(10.dp), - val cardShape: Shape = RoundedCornerShape(26.dp), - - val showImage: Boolean = true, - - val showTextSnippet: Boolean = true, - - val showHubsList: Boolean = true, - - val commentsButtonEnabled: Boolean = true, - - val addToBookmarksButtonEnabled: Boolean = false, - - val backgroundColor: Color = Color.White, - - val textColor: Color = Color.Black, - - val authorAvatarSize: Dp = 34.dp, - - val snippetMaxLines: Int = 4, - - val rippleColor: Color = textColor, - - val imageLoadingIndicatorColor: Color = SecondaryColor, - - val titleTextStyle: TextStyle = TextStyle( - color = textColor, - fontSize = 20.sp, - fontWeight = FontWeight.W700, - ), - - val snippetTextStyle: TextStyle = TextStyle( - color = textColor.copy(alpha = 0.75f), - fontSize = 16.sp, - fontWeight = FontWeight.W400, - lineHeight = 16.sp.times(1.25f) - ), - - val authorTextStyle: TextStyle = TextStyle( - color = textColor, - fontSize = 14.sp, - fontWeight = FontWeight.W600 - ), - - val publishedTimeTextStyle: TextStyle = TextStyle( - color = textColor.copy(alpha = 0.5f), - fontSize = 12.sp, - fontWeight = FontWeight.W400 - ), - - /** - * Text style of statistics row, note that text color for score indicator won't apply if it is non-zero value (will be red or green) - */ - val statisticsColor: Color = textColor.copy(alpha = 0.5f), - - val statisticsTextStyle: TextStyle = TextStyle( - color = statisticsColor, - fontSize = 15.sp, - fontWeight = FontWeight.W400 - ), - - val hubsTextStyle: TextStyle = TextStyle( - color = textColor.copy(alpha = 0.5f), - fontSize = 12.sp, - fontWeight = FontWeight.W600 - ) - -) - -@Composable -@ReadOnlyComposable -fun defaultArticleCardStyle(): ArticleCardStyle { - return ArticleCardStyle( - backgroundColor = MaterialTheme.colors.surface, - textColor = MaterialTheme.colors.onSurface, - statisticsColor = MaterialTheme.colors.onSurface - .copy( - alpha = if (MaterialTheme.colors.isLight) { - 0.75f - } else { - 0.5f - } - - ), - ) -} @OptIn(ExperimentalFoundationApi::class) @@ -150,7 +48,7 @@ fun ArticleCard( onClick: () -> Unit, onAuthorClick: () -> Unit, onCommentsClick: () -> Unit, - style: ArticleCardStyle = defaultArticleCardStyle().copy(addToBookmarksButtonEnabled = article.relatedData != null) + style: ArticleCardStyle ) { Column( @@ -191,46 +89,30 @@ fun ArticleCard( onClick = onAuthorClick ) ) { - if (it.avatarUrl.isNullOrBlank()) { - Icon( - modifier = Modifier - .size(style.authorAvatarSize) - .clip(style.innerElementsShape) - .background(Color.White) - .border( - BorderStroke( - 2.dp, - placeholderColorLegacy(article.author.alias) - ), - shape = style.innerElementsShape - ) - .padding(2.dp), - painter = painterResource(id = R.drawable.user_avatar_placeholder), - contentDescription = "", - tint = placeholderColorLegacy(article.author.alias) - ) - } else { - AsyncImage( - modifier = Modifier - .size(style.authorAvatarSize) - .clip(style.innerElementsShape) - .background(Color.White), - model = it.avatarUrl, - contentDescription = "avatar", - onState = { }) - } - Spacer(modifier = Modifier.width(4.dp)) + + AsyncImage( + modifier = Modifier + .size(style.authorAvatarSize) + .clip(style.innerElementsShape) + .background(Color.White), + model = it.avatarUrl, + contentDescription = "avatar", + onState = { }) + + Spacer(modifier = Modifier.width(8.dp)) Text( - it.alias, + modifier = Modifier.weight(1f), + text = it.alias, style = style.authorTextStyle, overflow = TextOverflow.Ellipsis, maxLines = 1 ) - Spacer(modifier = Modifier.width(4.dp)) } + } } + Spacer(modifier = Modifier.width(style.innerPadding)) // Published time Text( @@ -249,15 +131,15 @@ fun ArticleCard( ) Spacer(modifier = Modifier.height(0.dp)) Row( - modifier = Modifier.padding(horizontal = style.innerPadding), + modifier = Modifier.padding(horizontal = style.innerPadding, vertical = 2.dp), verticalAlignment = Alignment.CenterVertically, ) { - if (article.complexity != PostComplexity.None) { - val postComplexityColor = remember { + if (article.complexity != PublicationComplexity.None) { + val publicationComplexityColor = remember { when (article.complexity) { - PostComplexity.Low -> Color(0xFF4CBE51) - PostComplexity.Medium -> Color(0xFFEEBC25) - PostComplexity.High -> Color(0xFFEB3B2E) + PublicationComplexity.Low -> Color(0xFF4CBE51) + PublicationComplexity.Medium -> Color(0xFFEEBC25) + PublicationComplexity.High -> Color(0xFFEB3B2E) else -> style.statisticsColor } } @@ -265,17 +147,17 @@ fun ArticleCard( modifier = Modifier.size(height = 10.dp, width = 20.dp), painter = painterResource(id = R.drawable.speedmeter_hard), contentDescription = "", - tint = postComplexityColor + tint = publicationComplexityColor ) Spacer(modifier = Modifier.width(4.dp)) Text( text = when (article.complexity) { - PostComplexity.Low -> "Простой" - PostComplexity.Medium -> "Средний" - PostComplexity.High -> "Сложный" + PublicationComplexity.Low -> "Простой" + PublicationComplexity.Medium -> "Средний" + PublicationComplexity.High -> "Сложный" else -> "" }, - color = postComplexityColor, + color = publicationComplexityColor, fontWeight = FontWeight.W500, fontSize = 14.sp @@ -291,7 +173,7 @@ fun ArticleCard( ) Spacer(modifier = Modifier.width(4.dp)) Text( - text = "${article.readingTime} мин", + text = remember { "${article.readingTime} мин" }, color = style.statisticsColor, fontWeight = FontWeight.W500, fontSize = 14.sp @@ -314,15 +196,28 @@ fun ArticleCard( } } - var hubsText by remember { mutableStateOf("") } + var hubsText by remember { mutableStateOf(buildAnnotatedString { }) } LaunchedEffect(key1 = Unit, block = { - if (hubsText == "") { - hubsText = article.hubs!!.joinToString(separator = ", ") { - if (it.isProfiled) - (it.title + "*").replace(" ", "\u00A0") - else - it.title.replace(" ", "\u00A0") + if (hubsText.text == "") { + hubsText = buildAnnotatedString { + article.hubs!!.forEachIndexed { index, it -> + val textFunc = if (it.isProfiled) { + { append((it.title + "*").replace(" ", "\u00A0")) } + } else { + { append(it.title.replace(" ", "\u00A0")) } + } + if (it.relatedData != null && it.relatedData.isSubscribed) { + withStyle(SpanStyle(color = HubSubscribedColor)) { + textFunc() + } + } else { + textFunc() + } + if (index < article.hubs.size - 1) { + append(", ") + } + } } } }) @@ -335,7 +230,8 @@ fun ArticleCard( // Snippet - if (style.showTextSnippet) + if (style.showTextSnippet) { + Spacer(modifier = Modifier.height(2.dp)) Text( modifier = Modifier.padding(horizontal = style.innerPadding), text = article.textSnippet, @@ -343,10 +239,10 @@ fun ArticleCard( overflow = TextOverflow.Ellipsis, style = style.snippetTextStyle ) - + } // Image to draw attention (a.k.a. KDPV) if (style.showImage && !article.imageUrl.isNullOrBlank()) { - Spacer(modifier = Modifier.height(4.dp)) + Spacer(modifier = Modifier.height(6.dp)) AsyncImage( modifier = Modifier .padding(horizontal = style.innerPadding) @@ -394,14 +290,14 @@ fun ArticleCard( Text( text = '+' + article.statistics.score.toString(), style = style.statisticsTextStyle, - color = RatingPositive + color = RatingPositiveColor ) } else if (article.statistics.score < 0) { Text( text = article.statistics.score.toString(), style = style.statisticsTextStyle, - color = RatingNegative + color = RatingNegativeColor ) } else { Text( @@ -451,7 +347,11 @@ fun ArticleCard( addedToBookmarksCount-- addedToBookmarksCount = addedToBookmarksCount.coerceAtLeast(0) - if (!ArticleController.removeFromBookmarks(article.id, article.type == PostType.News)) { + if (!ArticleController.removeFromBookmarks( + article.id, + article.type == PostType.News + ) + ) { addedToBookmarks = true addedToBookmarksCount++ addedToBookmarksCount = @@ -461,7 +361,11 @@ fun ArticleCard( } else { addedToBookmarks = true addedToBookmarksCount++ - if (!ArticleController.addToBookmarks(article.id, article.type == PostType.News)) { + if (!ArticleController.addToBookmarks( + article.id, + article.type == PostType.News + ) + ) { addedToBookmarks = false addedToBookmarksCount-- addedToBookmarksCount = @@ -481,6 +385,8 @@ fun ArticleCard( } val hapticFeedback = LocalHapticFeedback.current + val addToBookmarksButtonEnabled = + remember { style.bookmarksButtonAllowedBeEnabled && article.relatedData != null } //Added to bookmarks Row( verticalAlignment = Alignment.CenterVertically, @@ -497,7 +403,7 @@ fun ArticleCard( HapticFeedbackType.LongPress ) }, - enabled = style.addToBookmarksButtonEnabled, + enabled = addToBookmarksButtonEnabled, ) .onGloballyPositioned { bounds = it.size @@ -527,26 +433,11 @@ fun ArticleCard( bounds = bounds, cardStyle = style, onSaveClick = { - coroutineScope.launch(Dispatchers.IO) { - val downloaded = OfflineArticlesController.downloadArticle(article.id, context) - if (downloaded) { - withContext(Dispatchers.Main) { - Toast.makeText(context, "Статья скачана!", Toast.LENGTH_SHORT).show() - } - } - } + OfflineArticlesController.downloadArticle(article.id, context) showPopup = false }, onDeleteClick = { - coroutineScope.launch(Dispatchers.IO) { - val deleted = OfflineArticlesController.deleteArticle(article.id, context) - if (deleted) { - withContext(Dispatchers.Main) { - Toast.makeText(context, "Статья удалена!", Toast.LENGTH_SHORT) - .show() - } - } - } + OfflineArticlesController.deleteArticle(article.id, context) showPopup = false }, onDismissRequest = { showPopup = false }, @@ -586,7 +477,7 @@ fun ArticleCard( modifier = Modifier .size(8.dp) .clip(CircleShape) - .background(RatingPositive) + .background(RatingPositiveColor) ) } } diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/common/ArticleCardSavePopup.kt b/app/src/main/java/com/garnegsoft/hubs/ui/common/feedCards/article/ArticleCardSavePopup.kt similarity index 81% rename from app/src/main/java/com/garnegsoft/hubs/ui/common/ArticleCardSavePopup.kt rename to app/src/main/java/com/garnegsoft/hubs/ui/common/feedCards/article/ArticleCardSavePopup.kt index a3829c9d..044b7a15 100644 --- a/app/src/main/java/com/garnegsoft/hubs/ui/common/ArticleCardSavePopup.kt +++ b/app/src/main/java/com/garnegsoft/hubs/ui/common/feedCards/article/ArticleCardSavePopup.kt @@ -1,43 +1,27 @@ -package com.garnegsoft.hubs.ui.common +package com.garnegsoft.hubs.ui.common.feedCards.article -import androidx.compose.animation.AnimatedContent -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.Transition import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.spring -import androidx.compose.animation.core.tween import androidx.compose.animation.core.updateTransition import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.material.CircularProgressIndicator import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.outlined.Delete import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow -import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.IntRect import androidx.compose.ui.unit.IntSize @@ -48,7 +32,6 @@ import androidx.compose.ui.window.PopupPositionProvider import androidx.compose.ui.window.PopupProperties import com.garnegsoft.hubs.R import com.garnegsoft.hubs.api.article.offline.OfflineArticlesDatabase -import kotlinx.coroutines.delay @Composable diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/common/feedCards/article/ArticleCardStyle.kt b/app/src/main/java/com/garnegsoft/hubs/ui/common/feedCards/article/ArticleCardStyle.kt new file mode 100644 index 00000000..f2f5fc7e --- /dev/null +++ b/app/src/main/java/com/garnegsoft/hubs/ui/common/feedCards/article/ArticleCardStyle.kt @@ -0,0 +1,179 @@ +package com.garnegsoft.hubs.ui.common.feedCards.article + +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.size.Size +import com.garnegsoft.hubs.api.dataStore.HubsDataStore +import com.garnegsoft.hubs.ui.theme.SecondaryColor + +/** + * Style of the [ArticleCard] + */ +@Immutable +data class ArticleCardStyle( + val innerPadding: Dp = 16.dp, + val innerElementsShape: Shape = RoundedCornerShape(10.dp), + val cardShape: Shape = RoundedCornerShape(26.dp), + + val showImage: Boolean = true, + + val showTextSnippet: Boolean = true, + + val showHubsList: Boolean = true, + + val commentsButtonEnabled: Boolean = true, + + /** + * Sets whether corresponding button will be clickable or not. + * Note that, even if this parameter set to true, + * button can be disabled if user haven't logged in yet. + * + * All you can do with this parameter is to restrict use of bookmarks button + */ + val bookmarksButtonAllowedBeEnabled: Boolean = true, + + val backgroundColor: Color = Color.White, + + val textColor: Color = Color.Black, + + val authorAvatarSize: Dp = 34.dp, + + val snippetMaxLines: Int = 4, + + val rippleColor: Color = textColor, + + val imageLoadingIndicatorColor: Color = SecondaryColor, + + val titleTextStyle: TextStyle = TextStyle( + color = textColor, + fontSize = 20.sp, + fontWeight = FontWeight.W700, + ), + + val snippetTextStyle: TextStyle = TextStyle( + color = textColor.copy(alpha = 0.75f), + fontSize = 16.sp, + fontWeight = FontWeight.W400, + ), + + val authorTextStyle: TextStyle = TextStyle( + color = textColor, + fontSize = 14.sp, + fontWeight = FontWeight.W600 + ), + + val publishedTimeTextStyle: TextStyle = TextStyle( + color = textColor.copy(alpha = 0.5f), + fontSize = 12.sp, + fontWeight = FontWeight.W400 + ), + + /** + * Text style of statistics row, + * note that statistics color can be overridden for score indicator + */ + val statisticsColor: Color = textColor.copy(alpha = 0.75f), + + val statisticsTextStyle: TextStyle = TextStyle( + color = statisticsColor, + fontSize = 15.sp, + fontWeight = FontWeight.W400 + ), + + val hubsTextStyle: TextStyle = TextStyle( + color = textColor.copy(alpha = 0.5f), + fontSize = 14.sp, + fontWeight = FontWeight.W500 + ) + +) { + companion object { + + @Composable + fun defaultArticleCardStyle(): ArticleCardStyle? { + val showImage by HubsDataStore.Settings.getValueFlow( + LocalContext.current, + HubsDataStore.Settings.ArticleCard.ShowImage + ).collectAsState(initial = null) + + val showTextSnippet by HubsDataStore.Settings.getValueFlow( + LocalContext.current, + HubsDataStore.Settings.ArticleCard.ShowTextSnippet + ).collectAsState(initial = null) + + val textSnippetFontSize by HubsDataStore.Settings.getValueFlow( + LocalContext.current, + HubsDataStore.Settings.ArticleCard.TextSnippetFontSize + ).collectAsState(initial = null) + + val textSnippetMaxLines by HubsDataStore.Settings.getValueFlow( + LocalContext.current, + HubsDataStore.Settings.ArticleCard.TextSnippetMaxLines + ).collectAsState(initial = null) + + val titleFontSize by HubsDataStore.Settings.getValueFlow( + LocalContext.current, + HubsDataStore.Settings.ArticleCard.TitleFontSize + ).collectAsState(initial = null) + + if (showImage == null || showTextSnippet == null + || textSnippetFontSize == null || textSnippetMaxLines == null + || titleFontSize == null) + return null + + val colors = MaterialTheme.colors + val defaultCardStyle = remember { + ArticleCardStyle(textColor = colors.onSurface, + statisticsColor = colors.onSurface.copy( + alpha = if (colors.isLight) { + 0.75f + } else { + 0.5f + } + + )) + } + + return defaultCardStyle.copy( + backgroundColor = MaterialTheme.colors.surface, + textColor = MaterialTheme.colors.onSurface, + statisticsColor = MaterialTheme.colors.onSurface + .copy( + alpha = if (MaterialTheme.colors.isLight) { + 0.75f + } else { + 0.5f + } + + ), + snippetTextStyle = defaultCardStyle.snippetTextStyle.copy( + fontSize = textSnippetFontSize!!.sp + ), + titleTextStyle = defaultCardStyle.titleTextStyle.copy(fontSize = titleFontSize!!.sp), + showImage = showImage!!, + showTextSnippet = showTextSnippet!!, + snippetMaxLines = textSnippetMaxLines!! + ) + } + } +} + +class lol { + var aboba: Int = 0 + +} + diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/common/CommentCard.kt b/app/src/main/java/com/garnegsoft/hubs/ui/common/feedCards/comment/CommentCard.kt similarity index 92% rename from app/src/main/java/com/garnegsoft/hubs/ui/common/CommentCard.kt rename to app/src/main/java/com/garnegsoft/hubs/ui/common/feedCards/comment/CommentCard.kt index bf684204..5725ee75 100644 --- a/app/src/main/java/com/garnegsoft/hubs/ui/common/CommentCard.kt +++ b/app/src/main/java/com/garnegsoft/hubs/ui/common/feedCards/comment/CommentCard.kt @@ -1,4 +1,4 @@ -package com.garnegsoft.hubs.ui.common +package com.garnegsoft.hubs.ui.common.feedCards.comment import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -30,8 +30,8 @@ import com.garnegsoft.hubs.api.comment.list.CommentSnippet import com.garnegsoft.hubs.api.utils.placeholderColorLegacy import com.garnegsoft.hubs.ui.screens.article.ElementSettings import com.garnegsoft.hubs.ui.screens.article.parseElement -import com.garnegsoft.hubs.ui.theme.RatingNegative -import com.garnegsoft.hubs.ui.theme.RatingPositive +import com.garnegsoft.hubs.ui.theme.RatingNegativeColor +import com.garnegsoft.hubs.ui.theme.RatingPositiveColor @Composable @@ -49,11 +49,11 @@ private fun defaultCommentCardStyle(): CommentCardStyle { @Composable fun CommentCard( - comment: CommentSnippet, - style: CommentCardStyle = defaultCommentCardStyle(), - onCommentClick: () -> Unit, - onAuthorClick: () -> Unit, - onParentPostClick: () -> Unit + comment: CommentSnippet, + style: CommentCardStyle = defaultCommentCardStyle(), + onCommentClick: () -> Unit, + onAuthorClick: () -> Unit, + onParentPostClick: () -> Unit ) { Column( modifier = Modifier @@ -85,7 +85,6 @@ fun CommentCard( ) Column( modifier = Modifier - .clickable(onClick = onCommentClick) .padding( top = style.padding .calculateTopPadding() @@ -99,13 +98,14 @@ fun CommentCard( Box( modifier = Modifier .weight(1f) + .clip(style.avatarShape) + .clickable(onClick = onAuthorClick) ) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .height(style.avatarSize) - .clip(style.avatarShape) - .clickable(onClick = onAuthorClick) + ) { if (comment.author.avatarUrl != null) { AsyncImage( @@ -132,12 +132,12 @@ fun CommentCard( tint = placeholderColorLegacy(comment.author.alias) ) } - Spacer(modifier = Modifier.width(4.dp)) + Spacer(modifier = Modifier.width(6.dp)) Text( text = comment.author.alias, style = style.authorAliasTextStyle ) - Spacer(modifier = Modifier.width(4.dp)) + } } @@ -180,8 +180,8 @@ fun CommentCard( "" } + it, color = when { - it > 0 -> RatingPositive - it < 0 -> RatingNegative + it > 0 -> RatingPositiveColor + it < 0 -> RatingNegativeColor else -> style.textColor } diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/common/CompanyUi.kt b/app/src/main/java/com/garnegsoft/hubs/ui/common/feedCards/company/CompanyCard.kt similarity index 88% rename from app/src/main/java/com/garnegsoft/hubs/ui/common/CompanyUi.kt rename to app/src/main/java/com/garnegsoft/hubs/ui/common/feedCards/company/CompanyCard.kt index ca187e98..03e6b123 100644 --- a/app/src/main/java/com/garnegsoft/hubs/ui/common/CompanyUi.kt +++ b/app/src/main/java/com/garnegsoft/hubs/ui/common/feedCards/company/CompanyCard.kt @@ -1,4 +1,4 @@ -package com.garnegsoft.hubs.ui.common +package com.garnegsoft.hubs.ui.common.feedCards.company import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -44,16 +44,10 @@ private fun defaultCompanyCardStyle(): CompanyCardStyle { @Composable fun CompanyCard( - company: CompanySnippet, - style: CompanyCardStyle = defaultCompanyCardStyle(), - indicator: @Composable () -> Unit = { - Text( - company.statistics.rating.toString(), - fontWeight = FontWeight.W400, - color = DefaultRatingIndicatorColor - ) - }, - onClick: () -> Unit + company: CompanySnippet, + style: CompanyCardStyle = defaultCompanyCardStyle(), + indicator: @Composable () -> Unit = { DefaultCompanyIndicator(company = company) }, + onClick: () -> Unit ) { Row( modifier = Modifier @@ -129,4 +123,18 @@ data class CompanyCardStyle( val indicatorValueTextStyle: TextStyle = TextStyle.Default.copy(color = Color.DarkGray), val showDescription: Boolean = false, val descriptionMaxLines: Int = 1 -) \ No newline at end of file +) + +@Composable +fun DefaultCompanyIndicator( + company: CompanySnippet +) { + val rating = remember { + String.format("%.1f", company.statistics.rating).replace(',', '.') + } + Text( + text = rating, + fontWeight = FontWeight.W400, + color = DefaultRatingIndicatorColor + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/common/HubUi.kt b/app/src/main/java/com/garnegsoft/hubs/ui/common/feedCards/hub/HubCard.kt similarity index 95% rename from app/src/main/java/com/garnegsoft/hubs/ui/common/HubUi.kt rename to app/src/main/java/com/garnegsoft/hubs/ui/common/feedCards/hub/HubCard.kt index 42f93bde..9488ad4f 100644 --- a/app/src/main/java/com/garnegsoft/hubs/ui/common/HubUi.kt +++ b/app/src/main/java/com/garnegsoft/hubs/ui/common/feedCards/hub/HubCard.kt @@ -1,4 +1,4 @@ -package com.garnegsoft.hubs.ui.common +package com.garnegsoft.hubs.ui.common.feedCards.hub import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -39,10 +39,10 @@ fun defaultHubCardStyle(): HubCardStyle { @Composable fun HubCard( - hub: HubSnippet, - style: HubCardStyle = defaultHubCardStyle(), - onClick: () -> Unit, - indicator: @Composable (hub: HubSnippet) -> Unit = { + hub: HubSnippet, + style: HubCardStyle = defaultHubCardStyle(), + onClick: () -> Unit, + indicator: @Composable (hub: HubSnippet) -> Unit = { Text( text = String.format("%.1f", hub.statistics.rating).replace(',', '.'), fontWeight = FontWeight.W400, diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/common/UserUi.kt b/app/src/main/java/com/garnegsoft/hubs/ui/common/feedCards/user/UserCard.kt similarity index 96% rename from app/src/main/java/com/garnegsoft/hubs/ui/common/UserUi.kt rename to app/src/main/java/com/garnegsoft/hubs/ui/common/feedCards/user/UserCard.kt index a8366a16..b70616f8 100644 --- a/app/src/main/java/com/garnegsoft/hubs/ui/common/UserUi.kt +++ b/app/src/main/java/com/garnegsoft/hubs/ui/common/feedCards/user/UserCard.kt @@ -1,4 +1,4 @@ -package com.garnegsoft.hubs.ui.common +package com.garnegsoft.hubs.ui.common.feedCards.user import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -51,12 +51,12 @@ private fun defaultUserCardStyle(): UserCardStyle { @Composable fun UserCard( - user: UserSnippet, - style: UserCardStyle = defaultUserCardStyle(), - indicator: @Composable () -> Unit = { + user: UserSnippet, + style: UserCardStyle = defaultUserCardStyle(), + indicator: @Composable () -> Unit = { Text(text = user.rating.toString(), fontWeight = FontWeight.W400, color = DefaultRatingIndicatorColor) }, - onClick: () -> Unit + onClick: () -> Unit ) { Row( modifier = Modifier diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/common/snippetsPages/ArticlesListPage.kt b/app/src/main/java/com/garnegsoft/hubs/ui/common/snippetsPages/ArticlesListPage.kt index 17d04df7..8b1eabed 100644 --- a/app/src/main/java/com/garnegsoft/hubs/ui/common/snippetsPages/ArticlesListPage.kt +++ b/app/src/main/java/com/garnegsoft/hubs/ui/common/snippetsPages/ArticlesListPage.kt @@ -5,11 +5,14 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.unit.dp import com.garnegsoft.hubs.api.CollapsingContentState import com.garnegsoft.hubs.api.Filter import com.garnegsoft.hubs.api.article.ArticlesListModel import com.garnegsoft.hubs.api.rememberCollapsingContentState -import com.garnegsoft.hubs.ui.common.ArticleCard +import com.garnegsoft.hubs.ui.common.feedCards.article.ArticleCard +import com.garnegsoft.hubs.ui.common.feedCards.article.ArticleCardStyle import com.garnegsoft.hubs.ui.common.FilterElement import kotlinx.coroutines.launch @@ -22,20 +25,25 @@ fun ArticlesListPage( onArticleSnippetClick: (articleId: Int) -> Unit, onArticleAuthorClick: (authorAlias: String) -> Unit, onArticleCommentsClick: (articleId: Int) -> Unit, - doInitialLoading: Boolean = true + doInitialLoading: Boolean = true, ) { - CommonPage( - listModel = listModel, - lazyListState = lazyListState, - collapsingBar = filterIndicator, - doInitialLoading = doInitialLoading - ) { - ArticleCard( - article = it, - onClick = { onArticleSnippetClick(it.id) }, - onAuthorClick = { it.author?.alias?.let { onArticleAuthorClick(it) } }, - onCommentsClick = { onArticleCommentsClick(it.id) } - ) + val cardsStyle = ArticleCardStyle.defaultArticleCardStyle() + + cardsStyle?.let { articleCardStyle -> + CommonPage( + listModel = listModel, + lazyListState = lazyListState, + collapsingBar = filterIndicator, + doInitialLoading = doInitialLoading + ) { + ArticleCard( + article = it, + onClick = { onArticleSnippetClick(it.id) }, + onAuthorClick = { it.author?.alias?.let { onArticleAuthorClick(it) } }, + onCommentsClick = { onArticleCommentsClick(it.id) }, + style = articleCardStyle + ) + } } } @@ -61,18 +69,24 @@ fun ArticlesListPageWithFilter( doInitialLoading: Boolean = true, filterDialog: @Composable (defaultValues: F, onDismiss: () -> Unit, onDone: (F) -> Unit) -> Unit, ) { - CommonPageWithFilter( - listModel = listModel, filterDialog = filterDialog, - filter = filter, - doInitialLoading = doInitialLoading, - lazyListState = lazyListState, - collapsingContentState = collapsingContentState - ) { - ArticleCard( - article = it, - onClick = { onArticleSnippetClick(it.id) }, - onAuthorClick = { it.author?.alias?.let { onArticleAuthorClick(it) } }, - onCommentsClick = { onArticleCommentsClick(it.id) } - ) + val cardsStyle = ArticleCardStyle.defaultArticleCardStyle() + + cardsStyle?.let { style -> + CommonPageWithFilter( + listModel = listModel, filterDialog = filterDialog, + filter = filter, + doInitialLoading = doInitialLoading, + lazyListState = lazyListState, + collapsingContentState = collapsingContentState + ) { + ArticleCard( + article = it, + onClick = { onArticleSnippetClick(it.id) }, + onAuthorClick = { it.author?.alias?.let { onArticleAuthorClick(it) } }, + onCommentsClick = { onArticleCommentsClick(it.id) }, + style = style + ) + } } + } \ No newline at end of file diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/common/snippetsPages/CommentsListPage.kt b/app/src/main/java/com/garnegsoft/hubs/ui/common/snippetsPages/CommentsListPage.kt index 6a9d5153..52e39ee8 100644 --- a/app/src/main/java/com/garnegsoft/hubs/ui/common/snippetsPages/CommentsListPage.kt +++ b/app/src/main/java/com/garnegsoft/hubs/ui/common/snippetsPages/CommentsListPage.kt @@ -4,7 +4,7 @@ import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import com.garnegsoft.hubs.api.comment.CommentsListModel -import com.garnegsoft.hubs.ui.common.CommentCard +import com.garnegsoft.hubs.ui.common.feedCards.comment.CommentCard @Composable diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/common/snippetsPages/CommonPage.kt b/app/src/main/java/com/garnegsoft/hubs/ui/common/snippetsPages/CommonPage.kt index bb592fd7..075acc5b 100644 --- a/app/src/main/java/com/garnegsoft/hubs/ui/common/snippetsPages/CommonPage.kt +++ b/app/src/main/java/com/garnegsoft/hubs/ui/common/snippetsPages/CommonPage.kt @@ -1,6 +1,8 @@ package com.garnegsoft.hubs.ui.common.snippetsPages +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.layout.fillMaxWidth import androidx.compose.foundation.lazy.LazyListState @@ -11,11 +13,13 @@ import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import com.garnegsoft.hubs.api.CollapsingContent import com.garnegsoft.hubs.api.CollapsingContentState import com.garnegsoft.hubs.api.Filter import com.garnegsoft.hubs.api.HabrSnippet import com.garnegsoft.hubs.api.article.AbstractSnippetListModel +import com.garnegsoft.hubs.api.article.HabrSnippetListModel import com.garnegsoft.hubs.api.rememberCollapsingContentState import com.garnegsoft.hubs.ui.common.FilterElement import com.garnegsoft.hubs.ui.common.LazyHabrSnippetsColumn @@ -24,7 +28,7 @@ import kotlinx.coroutines.launch @Composable fun CommonPage( - listModel: AbstractSnippetListModel, + listModel: HabrSnippetListModel, lazyListState: LazyListState = rememberLazyListState(), collapsingBar: (@Composable () -> Unit)? = null, doInitialLoading: Boolean = true, diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/common/snippetsPages/CompaniesListPage.kt b/app/src/main/java/com/garnegsoft/hubs/ui/common/snippetsPages/CompaniesListPage.kt index d5678d1a..857d383e 100644 --- a/app/src/main/java/com/garnegsoft/hubs/ui/common/snippetsPages/CompaniesListPage.kt +++ b/app/src/main/java/com/garnegsoft/hubs/ui/common/snippetsPages/CompaniesListPage.kt @@ -2,17 +2,13 @@ package com.garnegsoft.hubs.ui.common.snippetsPages import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight import com.garnegsoft.hubs.api.CollapsingContentState -import com.garnegsoft.hubs.api.article.AbstractSnippetListModel import com.garnegsoft.hubs.api.company.CompaniesListModel import com.garnegsoft.hubs.api.company.list.CompanySnippet import com.garnegsoft.hubs.api.rememberCollapsingContentState -import com.garnegsoft.hubs.ui.common.CompanyCard -import com.garnegsoft.hubs.ui.theme.DefaultRatingIndicatorColor +import com.garnegsoft.hubs.ui.common.feedCards.company.CompanyCard +import com.garnegsoft.hubs.ui.common.feedCards.company.DefaultCompanyIndicator @Composable @@ -23,13 +19,7 @@ fun CompaniesListPage( doInitialLoading: Boolean = true, collapsingContentState: CollapsingContentState = rememberCollapsingContentState(), onCompanyClick: (alias: String) -> Unit, - cardIndicator: @Composable (CompanySnippet) -> Unit = { - Text( - it.statistics.rating.toString(), - fontWeight = FontWeight.W400, - color = DefaultRatingIndicatorColor - ) - } + cardIndicator: @Composable (CompanySnippet) -> Unit = { DefaultCompanyIndicator(company = it) } ) { CommonPage( listModel = listModel, diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/common/snippetsPages/HubsListPage.kt b/app/src/main/java/com/garnegsoft/hubs/ui/common/snippetsPages/HubsListPage.kt index 6d6a768f..5127931a 100644 --- a/app/src/main/java/com/garnegsoft/hubs/ui/common/snippetsPages/HubsListPage.kt +++ b/app/src/main/java/com/garnegsoft/hubs/ui/common/snippetsPages/HubsListPage.kt @@ -8,7 +8,7 @@ import com.garnegsoft.hubs.api.article.AbstractSnippetListModel import com.garnegsoft.hubs.api.hub.HubsListModel import com.garnegsoft.hubs.api.hub.list.HubSnippet import com.garnegsoft.hubs.api.rememberCollapsingContentState -import com.garnegsoft.hubs.ui.common.HubCard +import com.garnegsoft.hubs.ui.common.feedCards.hub.HubCard @Composable diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/common/snippetsPages/UserListPage.kt b/app/src/main/java/com/garnegsoft/hubs/ui/common/snippetsPages/UserListPage.kt index 6229289b..48bc3dde 100644 --- a/app/src/main/java/com/garnegsoft/hubs/ui/common/snippetsPages/UserListPage.kt +++ b/app/src/main/java/com/garnegsoft/hubs/ui/common/snippetsPages/UserListPage.kt @@ -8,7 +8,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import com.garnegsoft.hubs.api.user.UsersListModel import com.garnegsoft.hubs.api.user.list.UserSnippet -import com.garnegsoft.hubs.ui.common.UserCard +import com.garnegsoft.hubs.ui.common.feedCards.user.UserCard import com.garnegsoft.hubs.ui.theme.DefaultRatingIndicatorColor diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/screens/ImageViewerScreen.kt b/app/src/main/java/com/garnegsoft/hubs/ui/screens/ImageViewerScreen.kt index e73b76a9..eb85670e 100644 --- a/app/src/main/java/com/garnegsoft/hubs/ui/screens/ImageViewerScreen.kt +++ b/app/src/main/java/com/garnegsoft/hubs/ui/screens/ImageViewerScreen.kt @@ -1,8 +1,14 @@ package com.garnegsoft.hubs.ui.screens +import android.graphics.BitmapFactory +import android.net.Uri import android.os.Build import android.util.Log +import androidx.compose.animation.core.AnimationState +import androidx.compose.animation.core.animateDecay import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.rememberSplineBasedDecay +import androidx.compose.animation.splineBasedDecay import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.* @@ -24,15 +30,26 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp +import androidx.core.graphics.drawable.toDrawable +import coil.decode.DataSource import coil.decode.GifDecoder import coil.decode.ImageDecoderDecoder import coil.decode.SvgDecoder +import coil.fetch.DrawableResult +import coil.fetch.FetchResult +import coil.fetch.Fetcher import coil.request.ImageRequest +import coil.size.Size +import com.garnegsoft.hubs.api.CommonImageRequestFetcher +import com.garnegsoft.hubs.api.offlineResourcesDir import com.google.accompanist.systemuicontroller.rememberSystemUiController +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch import me.saket.telephoto.zoomable.ZoomSpec import me.saket.telephoto.zoomable.coil.ZoomableAsyncImage import me.saket.telephoto.zoomable.rememberZoomableImageState import me.saket.telephoto.zoomable.rememberZoomableState +import java.io.File import kotlin.math.absoluteValue import kotlin.math.roundToInt @@ -40,117 +57,137 @@ import kotlin.math.roundToInt @OptIn(ExperimentalMaterialApi::class) @Composable fun ImageViewScreen( - model: Any, - onBack: () -> Unit + model: Any, + onBack: () -> Unit ) { - - val zoomableState = rememberZoomableState( - autoApplyTransformations = false, - zoomSpec = ZoomSpec(maxZoomFactor = 3f, preventOverOrUnderZoom = false) - ) - - val state = rememberZoomableImageState(zoomableState = zoomableState) - - val systemUiController = rememberSystemUiController() - val statusBarColor = MaterialTheme.colors.let { - if (it.isLight) - it.primary - else - it.surface - } - DisposableEffect(key1 = Unit, effect = { - systemUiController.setStatusBarColor(Color.Black) - onDispose { - systemUiController.setStatusBarColor(statusBarColor) - } - }) - var offset by remember { - mutableStateOf(0f) - } - - val draggableState = rememberDraggableState { - offset += it - } - var isDragging by remember { mutableStateOf(false) } - val animatedOffset by animateFloatAsState( - targetValue = if (isDragging) offset else 0f) - - - val screenHeight = LocalConfiguration.current.screenHeightDp * LocalDensity.current.density - - Box(modifier = Modifier - .fillMaxSize() - .draggable( - state = draggableState, - orientation = Orientation.Vertical, - enabled = true, - onDragStarted = { - isDragging = true - }, - onDragStopped = { - if (it.absoluteValue > 8000f || offset.absoluteValue > screenHeight / 3){ - onBack() - } else { - isDragging = false - offset = 0f - } - } - ) - .background(Color.Black)){ - - - ZoomableAsyncImage( - modifier = Modifier - .fillMaxSize() - .offset { IntOffset(0, (if (isDragging) offset else animatedOffset).roundToInt()) }, - state = state, - model = - ImageRequest.Builder(LocalContext.current) - .data(model) - .decoderFactory(SvgDecoder.Factory()) - .decoderFactory( - if (Build.VERSION.SDK_INT >= 28) { - ImageDecoderDecoder.Factory() - } else { - GifDecoder.Factory() - } - ) - .crossfade(true) - .build(), - contentDescription = null, - clipToBounds = false - ) - Row(modifier = Modifier - .fillMaxWidth() - .height(55.dp) - .padding(horizontal = 4.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.End - ) { - CompositionLocalProvider(LocalRippleTheme provides customRippleTheme) { - Box(modifier = Modifier - .size(48.dp) - .clip(CircleShape) - .clickable(onClick = onBack) - .background(Color.Black.copy(0.5f)), - contentAlignment = Alignment.Center - ) { - Icon(imageVector = Icons.Default.Close, contentDescription = "", tint = Color.White) - } - } - } - } + val context = LocalContext.current + val zoomableState = rememberZoomableState( + autoApplyTransformations = false, + zoomSpec = ZoomSpec(maxZoomFactor = 3f, preventOverOrUnderZoom = false) + ) + + val state = rememberZoomableImageState(zoomableState = zoomableState) + + val systemUiController = rememberSystemUiController() + val statusBarColor = MaterialTheme.colors.let { + if (it.isLight) + it.primary + else + it.surface + } + DisposableEffect(key1 = Unit, effect = { + systemUiController.setStatusBarColor(Color.Black) + onDispose { + systemUiController.setStatusBarColor(statusBarColor) + } + }) + var offset by remember { + mutableStateOf(0f) + } + + val draggableState = rememberDraggableState { + offset += it + } + var isDragging by remember { mutableStateOf(false) } + val animatedOffset by animateFloatAsState( + targetValue = if (isDragging) offset else 0f + ) + + + val screenHeight = LocalConfiguration.current.screenHeightDp * LocalDensity.current.density + val splineBasedDecay = rememberSplineBasedDecay() + Box(modifier = Modifier + .fillMaxSize() + .draggable( + state = draggableState, + orientation = Orientation.Vertical, + enabled = true, + onDragStarted = { + isDragging = true + }, + onDragStopped = { + if (it.absoluteValue > 8000f || offset.absoluteValue > screenHeight / 3) { + launch { + var lastValue = 0f + AnimationState( + initialValue = 0f, + initialVelocity = it + ).animateDecay(splineBasedDecay) { + offset += value - lastValue + lastValue = value + } + } + launch { + delay(50) + onBack() + } + } else { + isDragging = false + offset = 0f + } + } + ) + .background(Color.Black)) { + + + ZoomableAsyncImage( + modifier = Modifier + .fillMaxSize() + .offset { IntOffset(0, (if (isDragging) offset else animatedOffset).roundToInt()) }, + state = state, + model = ImageRequest.Builder(LocalContext.current) + .data(model) + //.decoderFactory(SvgDecoder.Factory()) + .decoderFactory( + if (Build.VERSION.SDK_INT >= 28) { + ImageDecoderDecoder.Factory() + } else { + GifDecoder.Factory() + } + ) + .size(Size.ORIGINAL) + .crossfade(true) + .build(), + contentDescription = null, + clipToBounds = false + ) + Row( + modifier = Modifier + .fillMaxWidth() + .height(55.dp) + .padding(horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.End + ) { + CompositionLocalProvider(LocalRippleTheme provides customRippleTheme) { + Box( + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .clickable(onClick = onBack) + .background(Color.Black.copy(0.5f)), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Default.Close, + contentDescription = "", + tint = Color.White + ) + } + } + } + } } val customRippleTheme = object : RippleTheme { - @Composable - override fun defaultColor(): Color { - return Color.White - } - - @Composable - override fun rippleAlpha(): RippleAlpha { - return RippleAlpha(0.5f, 0.5f, 0.5f, 0.5f) - } - + @Composable + override fun defaultColor(): Color { + return Color.White + } + + @Composable + override fun rippleAlpha(): RippleAlpha { + return RippleAlpha(0.5f, 0.5f, 0.5f, 0.5f) + } + } \ No newline at end of file diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/screens/article/ArticleContent.kt b/app/src/main/java/com/garnegsoft/hubs/ui/screens/article/ArticleContent.kt index c6ff9294..65cf61ee 100644 --- a/app/src/main/java/com/garnegsoft/hubs/ui/screens/article/ArticleContent.kt +++ b/app/src/main/java/com/garnegsoft/hubs/ui/screens/article/ArticleContent.kt @@ -35,14 +35,12 @@ import coil.compose.AsyncImage import com.garnegsoft.hubs.R import com.garnegsoft.hubs.api.EditorVersion import com.garnegsoft.hubs.api.dataStore.HubsDataStore -import com.garnegsoft.hubs.api.PostComplexity +import com.garnegsoft.hubs.api.PublicationComplexity import com.garnegsoft.hubs.api.PostType import com.garnegsoft.hubs.api.article.Article import com.garnegsoft.hubs.api.company.CompanyController -import com.garnegsoft.hubs.api.dataStore.settingsDataStoreFlowWithDefault -import com.garnegsoft.hubs.api.utils.placeholderColorLegacy import com.garnegsoft.hubs.ui.common.TitledColumn -import com.garnegsoft.hubs.ui.screens.user.HubChip +import com.garnegsoft.hubs.ui.common.HubChip import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.jsoup.nodes.* @@ -66,20 +64,13 @@ fun ArticleContent( val mostReadingArticles by viewModel.mostReadingArticles.observeAsState() Box() { - val flingSpec = rememberSplineBasedDecay() val contentNodes by viewModel.parsedArticleContent.observeAsState() - val fontSize by context.settingsDataStoreFlowWithDefault( - HubsDataStore.Settings.Keys.ArticleScreen.FontSize, - MaterialTheme.typography.body1.fontSize.value - ).collectAsState( - initial = null - ) - val lineHeightFactor by context.settingsDataStoreFlowWithDefault( - HubsDataStore.Settings.Keys.ArticleScreen.LineHeightFactor, - 1.5f - ).collectAsState( - initial = null - ) + val fontSize by HubsDataStore.Settings + .getValueFlow(context, HubsDataStore.Settings.ArticleScreen.FontSize) + .collectAsState( + initial = null + ) + val color = MaterialTheme.colors.onSurface val spanStyle = remember(fontSize, color) { SpanStyle( @@ -94,17 +85,13 @@ fun ArticleContent( fitScreenWidth = false ) } - val state = rememberLazyListState() val updatedPolls by viewModel.updatedPolls.observeAsState() - LazyColumn( - modifier = Modifier - .fillMaxSize(), + modifier = Modifier.fillMaxSize(), state = state, - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(0.dp) + contentPadding = PaddingValues(16.dp) ) { if (article.editorVersion == EditorVersion.FirstVersion) { item { @@ -126,7 +113,6 @@ fun ArticleContent( color = MaterialTheme.colors.onError ) } - Spacer(modifier = Modifier.height(16.dp)) } } @@ -134,16 +120,18 @@ fun ArticleContent( if (article.postType == PostType.Megaproject && article.metadata != null) { item { AsyncImage( - article.metadata.mainImageUrl, - "", - modifier = Modifier.clip(RoundedCornerShape(8.dp)), + model = article.metadata.mainImageUrl, + contentDescription = "", + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)), ) Spacer(Modifier.height(8.dp)) } } if (article.isCorporative) { - item { + item { val companyAlias = article.hubs.find { it.isCorporative }!!.alias var companyTitle: String? by rememberSaveable { @@ -185,8 +173,7 @@ fun ArticleContent( fontSize = 14.sp, color = MaterialTheme.colors.onBackground ) - } - else { + } else { Box(modifier = Modifier.size(34.dp)) } @@ -205,33 +192,16 @@ fun ArticleContent( .clickable(onClick = { onAuthorClicked() }), verticalAlignment = Alignment.CenterVertically, ) { - if (article.author.avatarUrl != null) + if (article.author.avatarUrl != null) { AsyncImage( modifier = Modifier - .size(34.dp) + .size(38.dp) .clip(RoundedCornerShape(8.dp)) .background(Color.White), model = article.author.avatarUrl, contentDescription = "" ) - else - Icon( - modifier = Modifier - .size(34.dp) - .border( - width = 2.dp, - color = placeholderColorLegacy(article.author.alias), - shape = RoundedCornerShape(8.dp) - ) - .background( - Color.White, - shape = RoundedCornerShape(8.dp) - ) - .padding(2.dp), - painter = painterResource(id = R.drawable.user_avatar_placeholder), - contentDescription = "", - tint = placeholderColorLegacy(article.author.alias) - ) + } Spacer(modifier = Modifier.width(4.dp)) Text( text = article.author.alias, fontWeight = FontWeight.W600, @@ -239,51 +209,64 @@ fun ArticleContent( color = MaterialTheme.colors.onBackground ) Spacer(modifier = Modifier.weight(1f)) - Text( - article.timePublished, color = Color.Gray, - fontSize = 12.sp, fontWeight = FontWeight.W400 - ) + } Spacer(modifier = Modifier.height(8.dp)) } } item { + fontSize?.let { + Text( + text = article.title, + fontSize = (it + 4f).sp, + fontWeight = FontWeight.W700, + color = MaterialTheme.colors.onBackground + ) + } - Text( - text = article.title, - fontSize = 22.sp, - fontWeight = FontWeight.W700, - color = MaterialTheme.colors.onBackground - ) Spacer(modifier = Modifier.height(4.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Start + ) { + Text( + text = article.timePublished, + color = MaterialTheme.colors.onBackground.copy(0.6f), + fontSize = 14.sp, + fontWeight = FontWeight.W500 + ) + } + + Spacer(Modifier.height(4.dp)) + Row( verticalAlignment = Alignment.CenterVertically, ) { - if (article.complexity != PostComplexity.None) { + if (article.complexity != PublicationComplexity.None) { Icon( modifier = Modifier.size(height = 10.dp, width = 20.dp), painter = painterResource(id = R.drawable.speedmeter_hard), contentDescription = "", tint = when (article.complexity) { - PostComplexity.Low -> Color(0xFF4CBE51) - PostComplexity.Medium -> Color(0xFFEEBC25) - PostComplexity.High -> Color(0xFFEB3B2E) + PublicationComplexity.Low -> Color(0xFF4CBE51) + PublicationComplexity.Medium -> Color(0xFFEEBC25) + PublicationComplexity.High -> Color(0xFFEB3B2E) else -> Color.Red } ) Spacer(modifier = Modifier.width(4.dp)) Text( text = when (article.complexity) { - PostComplexity.Low -> "Простой" - PostComplexity.Medium -> "Средний" - PostComplexity.High -> "Сложный" + PublicationComplexity.Low -> "Простой" + PublicationComplexity.Medium -> "Средний" + PublicationComplexity.High -> "Сложный" else -> "" }, color = when (article.complexity) { - PostComplexity.Low -> Color(0xFF4CBE51) - PostComplexity.Medium -> Color(0xFFEEBC25) - PostComplexity.High -> Color(0xFFEB3B2E) + PublicationComplexity.Low -> Color(0xFF4CBE51) + PublicationComplexity.Medium -> Color(0xFFEEBC25) + PublicationComplexity.High -> Color(0xFFEB3B2E) else -> Color.Red }, fontWeight = FontWeight.W500, @@ -379,10 +362,7 @@ fun ArticleContent( FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { article.hubs.forEach { HubChip( - if (it.isProfiled) - it.title + "*" - else - it.title + title = if (it.isProfiled) it.title + "*" else it.title ) { if (it.isCorporative) onCompanyClick(it.alias) @@ -444,20 +424,21 @@ fun ArticleContent( } mostReadingArticles?.let { items(it) { - Box(modifier = Modifier - .clip(RoundedCornerShape(8.dp)) - .clickable { onArticleClick(it.id) } - .padding(bottom = 8.dp) - .padding(8.dp) - ) { - ArticleShort(article = it) - } + ArticleShort( + article = it, + onClick = { + onArticleClick(it.id) + } + ) } } } - val scrollBarAlpha by animateFloatAsState(targetValue = if (state.isScrollInProgress) 1f else 0f, tween(600)) + val scrollBarAlpha by animateFloatAsState( + targetValue = if (state.isScrollInProgress) 1f else 0f, + tween(600) + ) val density = LocalDensity.current val scrollBarColor = MaterialTheme.colors.onBackground.copy(0.25f) diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/screens/article/ArticleScreen.kt b/app/src/main/java/com/garnegsoft/hubs/ui/screens/article/ArticleScreen.kt index 87cd287e..86fe8878 100644 --- a/app/src/main/java/com/garnegsoft/hubs/ui/screens/article/ArticleScreen.kt +++ b/app/src/main/java/com/garnegsoft/hubs/ui/screens/article/ArticleScreen.kt @@ -5,10 +5,8 @@ import android.content.Intent import androidx.compose.animation.* import androidx.compose.animation.core.AnimationState import androidx.compose.animation.core.animateDecay -import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween -import androidx.compose.animation.core.updateTransition import androidx.compose.foundation.* import androidx.compose.foundation.gestures.FlingBehavior import androidx.compose.foundation.gestures.ScrollScope @@ -37,27 +35,24 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.* import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.* -import androidx.compose.ui.window.Popup -import androidx.compose.ui.window.PopupPositionProvider -import androidx.datastore.preferences.core.edit import androidx.lifecycle.ViewModelStoreOwner import androidx.lifecycle.viewmodel.compose.viewModel -import coil.compose.AsyncImage import com.garnegsoft.hubs.R +import com.garnegsoft.hubs.api.AsyncGifImage import com.garnegsoft.hubs.api.PostType import com.garnegsoft.hubs.api.dataStore.HubsDataStore +import com.garnegsoft.hubs.api.dataStore.LastReadArticleController +import com.garnegsoft.hubs.api.history.HistoryController import com.garnegsoft.hubs.api.utils.formatLongNumbers import com.garnegsoft.hubs.api.utils.placeholderColorLegacy -import com.garnegsoft.hubs.lastReadDataStore -import com.garnegsoft.hubs.api.dataStore.settingsDataStoreFlowWithDefault import com.garnegsoft.hubs.api.utils.formatTime import com.garnegsoft.hubs.ui.common.TitledColumn -import com.garnegsoft.hubs.ui.screens.user.HubChip -import com.garnegsoft.hubs.ui.theme.RatingNegative -import com.garnegsoft.hubs.ui.theme.RatingPositive +import com.garnegsoft.hubs.ui.common.HubChip +import com.garnegsoft.hubs.ui.theme.RatingNegativeColor +import com.garnegsoft.hubs.ui.theme.RatingPositiveColor import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.jsoup.Jsoup import org.jsoup.nodes.* import kotlin.math.abs @@ -78,12 +73,11 @@ fun ArticleScreen( isOffline: Boolean = false ) { val context = LocalContext.current - val fontSize by context.settingsDataStoreFlowWithDefault( - HubsDataStore.Settings.Keys.ArticleScreen.FontSize, - MaterialTheme.typography.body1.fontSize.value - ).collectAsState( - initial = null - ) + val fontSize by HubsDataStore.Settings + .getValueFlow(context, HubsDataStore.Settings.ArticleScreen.FontSize) + .collectAsState( + initial = null + ) val viewModel = viewModel(viewModelStoreOwner) val article by viewModel.article.observeAsState() val offlineArticle by viewModel.offlineArticle.observeAsState() @@ -93,6 +87,7 @@ fun ArticleScreen( if (isOffline) { viewModel.loadArticleFromLocalDatabase(articleId, context) } else { + viewModel.loadArticle(articleId) } } @@ -160,6 +155,7 @@ fun ArticleScreen( backgroundColor = if (MaterialTheme.colors.isLight) MaterialTheme.colors.surface else MaterialTheme.colors.background, bottomBar = { article?.let { article -> + BottomAppBar( elevation = 0.dp, backgroundColor = MaterialTheme.colors.surface, @@ -200,9 +196,9 @@ fun ArticleScreen( else article.statistics.score.toString(), color = if (article.statistics.score > 0) - RatingPositive + RatingPositiveColor else if (article.statistics.score < 0) - RatingNegative + RatingNegativeColor else statisticsColor, fontWeight = FontWeight.W500 @@ -254,7 +250,11 @@ fun ArticleScreen( addedToBookmarksCount-- addedToBookmarksCount = addedToBookmarksCount.coerceAtLeast(0) - if (!ArticleController.removeFromBookmarks(article.id, article.postType == PostType.News)) { + if (!ArticleController.removeFromBookmarks( + article.id, + article.postType == PostType.News + ) + ) { addedToBookmarks = true addedToBookmarksCount++ addedToBookmarksCount = @@ -264,7 +264,11 @@ fun ArticleScreen( } else { addedToBookmarks = true addedToBookmarksCount++ - if (!ArticleController.addToBookmarks(article.id, article.postType == PostType.News)) { + if (!ArticleController.addToBookmarks( + article.id, + article.postType == PostType.News + ) + ) { addedToBookmarks = false addedToBookmarksCount-- addedToBookmarksCount = @@ -310,7 +314,7 @@ fun ArticleScreen( modifier = Modifier .size(8.dp) .clip(CircleShape) - .background(RatingPositive) + .background(RatingPositiveColor) ) } } @@ -385,7 +389,7 @@ fun ArticleScreen( verticalAlignment = Alignment.CenterVertically, ) { if (article.authorAvatarUrl != null) - AsyncImage( + AsyncGifImage( modifier = Modifier .size(34.dp) .clip(RoundedCornerShape(8.dp)) @@ -587,13 +591,6 @@ fun ArticleScreen( } } else { article?.let { article -> - - val lineHeightFactor by context.settingsDataStoreFlowWithDefault( - HubsDataStore.Settings.Keys.ArticleScreen.LineHeightFactor, - 1.5f - ).collectAsState( - initial = null - ) val color = MaterialTheme.colors.onSurface val spanStyle = remember(fontSize, color) { SpanStyle( @@ -612,8 +609,9 @@ fun ArticleScreen( mutableStateOf(false) } LaunchedEffect(key1 = Unit, block = { - context.lastReadDataStore.edit { - it[HubsDataStore.LastRead.Keys.LastArticleRead] = articleId + LastReadArticleController.setLastArticle(context, articleId) + withContext(Dispatchers.IO){ + HistoryController.insertArticle(articleId, context) } if (!viewModel.parsedArticleContent.isInitialized && fontSize != null) { val element = @@ -631,19 +629,20 @@ fun ArticleScreen( nodeParsed = true } }) - LaunchedEffect(key1 = fontSize, block = { - }) Box(modifier = Modifier.padding(it)) { - if (nodeParsed && fontSize != null) - ArticleContent( - article = article, - onAuthorClicked = { onAuthorClicked(article.author!!.alias) }, - onHubClicked = onHubClicked, - onCompanyClick = onCompanyClick, - onViewImageRequest = onViewImageRequest, - onArticleClick = onArticleClick - ) + if (nodeParsed && fontSize != null) { + SelectionContainer { + ArticleContent( + article = article, + onAuthorClicked = { onAuthorClicked(article.author!!.alias) }, + onHubClicked = onHubClicked, + onCompanyClick = onCompanyClick, + onViewImageRequest = onViewImageRequest, + onArticleClick = onArticleClick + ) + } + } } } ?: Box( modifier = Modifier diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/screens/article/ArticleScreenViewModel.kt b/app/src/main/java/com/garnegsoft/hubs/ui/screens/article/ArticleScreenViewModel.kt index a79597fc..001cd14d 100644 --- a/app/src/main/java/com/garnegsoft/hubs/ui/screens/article/ArticleScreenViewModel.kt +++ b/app/src/main/java/com/garnegsoft/hubs/ui/screens/article/ArticleScreenViewModel.kt @@ -6,105 +6,96 @@ import android.content.Context import android.util.Log import android.widget.Toast import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.composed import androidx.compose.ui.text.SpanStyle import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.garnegsoft.hubs.api.HabrList import com.garnegsoft.hubs.api.article.Article import com.garnegsoft.hubs.api.article.list.ArticleSnippet -import com.garnegsoft.hubs.api.article.offline.HubsList import com.garnegsoft.hubs.api.article.offline.OfflineArticle -import com.garnegsoft.hubs.api.article.offline.OfflineArticleSnippet import com.garnegsoft.hubs.api.article.offline.OfflineArticlesController import com.garnegsoft.hubs.api.article.offline.offlineArticlesDatabase import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class ArticleScreenViewModel : ViewModel() { - private var _article = MutableLiveData() - val article: LiveData get() = _article - - private var _offlineArticle = MutableLiveData() - val offlineArticle: LiveData get() = _offlineArticle - - fun loadArticle(id: Int) { - viewModelScope.launch(Dispatchers.IO) { - ArticleController.get(id)?.let { - _article.postValue(it) - - } - } - } - - fun loadArticleFromLocalDatabase(id: Int, context: Context) { - viewModelScope.launch(Dispatchers.IO){ - val dao = context.offlineArticlesDatabase.articlesDao() - if (dao.exists(id)) { - _offlineArticle.postValue(dao._getArticleById(id)) - - } else { - withContext(Dispatchers.Main){ - Toast.makeText(context, "Статья не найдена в скачанных\nПопробуйте скачать ее заново", Toast.LENGTH_SHORT).show() - } - } - } - } - - fun saveArticle(id: Int, context: Context){ - viewModelScope.launch(Dispatchers.IO){ - OfflineArticlesController.downloadArticle(id, context) - withContext(Dispatchers.Main){ - Toast.makeText(context, "Статья скачана!", Toast.LENGTH_SHORT).show() - } - Log.e("offlineArticle", "loading done") - } - } - - fun deleteSavedArticle(id: Int, context: Context){ - viewModelScope.launch(Dispatchers.IO) { - OfflineArticlesController.deleteArticle(id, context) - withContext(Dispatchers.Main) { - Toast.makeText(context, "Статья удалена!", Toast.LENGTH_SHORT).show() - } - } - } - - fun articleExists(context: Context, articleId: Int): Flow { - return context.offlineArticlesDatabase.articlesDao().existsFlow(articleId) - } - - private var _mostReadingArticles = MutableLiveData>() - val mostReadingArticles: LiveData> get() = _mostReadingArticles - - fun loadMostReading() { - viewModelScope.launch(Dispatchers.IO) { - ArticlesListController.getArticlesSnippets("articles/most-reading")?.let { - _mostReadingArticles.postValue(it.list.take(5)) - } - } - } - - private val _updatedPolls = MutableLiveData>() - val updatedPolls: LiveData> get() = _updatedPolls - - fun vote(pollId: Int, variantsIds: List) { - viewModelScope.launch(Dispatchers.IO) { - ArticleController.vote(pollId = pollId, variantsIds = variantsIds)?.let { poll -> - _updatedPolls.value?.let { - _updatedPolls.postValue(it + poll) - } - } - } - } - - val parsedArticleContent = MutableLiveData Unit)?>>() - + private var _article = MutableLiveData() + val article: LiveData get() = _article + + private var _offlineArticle = MutableLiveData() + val offlineArticle: LiveData get() = _offlineArticle + + fun loadArticle(id: Int) { + viewModelScope.launch(Dispatchers.IO) { + ArticleController.get(id)?.let { + _article.postValue(it) + + } + } + } + + fun loadArticleFromLocalDatabase(id: Int, context: Context) { + viewModelScope.launch(Dispatchers.IO) { + val dao = context.offlineArticlesDatabase.articlesDao() + if (dao.exists(id)) { + _offlineArticle.postValue(dao.getArticleById(id)) + + } else { + withContext(Dispatchers.Main) { + Toast.makeText( + context, + "Статья не найдена в скачанных\nПопробуйте скачать ее заново", + Toast.LENGTH_SHORT + ).show() + } + } + } + } + + fun saveArticle(id: Int, context: Context) { + viewModelScope.launch(Dispatchers.IO) { + OfflineArticlesController.downloadArticle(id, context) + Log.e("offlineArticle", "loading done") + } + } + + fun deleteSavedArticle(id: Int, context: Context) { + OfflineArticlesController.deleteArticle(id, context) + } + + fun articleExists(context: Context, articleId: Int): Flow { + return context.offlineArticlesDatabase.articlesDao().existsFlow(articleId) + } + + private var _mostReadingArticles = MutableLiveData>() + val mostReadingArticles: LiveData> get() = _mostReadingArticles + + fun loadMostReading() { + viewModelScope.launch(Dispatchers.IO) { + ArticlesListController.getArticlesSnippets("articles/most-reading")?.let { + _mostReadingArticles.postValue(it.list.take(5)) + } + } + } + + private val _updatedPolls = MutableLiveData>() + val updatedPolls: LiveData> get() = _updatedPolls + + fun vote(pollId: Int, variantsIds: List) { + viewModelScope.launch(Dispatchers.IO) { + ArticleController.vote(pollId = pollId, variantsIds = variantsIds)?.let { poll -> + _updatedPolls.value?.let { + _updatedPolls.postValue(it + poll) + } + } + } + } + + val parsedArticleContent = + MutableLiveData Unit)?>>() + } \ No newline at end of file diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/screens/article/ArticleShort.kt b/app/src/main/java/com/garnegsoft/hubs/ui/screens/article/ArticleShort.kt index 05078790..422db311 100644 --- a/app/src/main/java/com/garnegsoft/hubs/ui/screens/article/ArticleShort.kt +++ b/app/src/main/java/com/garnegsoft/hubs/ui/screens/article/ArticleShort.kt @@ -1,40 +1,46 @@ package com.garnegsoft.hubs.ui.screens.article +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Icon import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.garnegsoft.hubs.R import com.garnegsoft.hubs.api.article.list.ArticleSnippet import com.garnegsoft.hubs.api.utils.formatLongNumbers -import com.garnegsoft.hubs.ui.theme.HubsTheme -import com.garnegsoft.hubs.ui.theme.RatingNegative -import com.garnegsoft.hubs.ui.theme.RatingPositive +import com.garnegsoft.hubs.ui.theme.RatingNegativeColor +import com.garnegsoft.hubs.ui.theme.RatingPositiveColor @Composable fun ArticleShort( - article: ArticleSnippet + article: ArticleSnippet, + onClick: () -> Unit ) { - -// Divider(modifier = Modifier.padding(16.dp)) -// Spacer(modifier = Modifier.height(32.dp)) - Column() { + + Column( + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .clickable(onClick = onClick) + .padding(bottom = 8.dp) + .padding(8.dp) + ) { Text( text = article.title, color = MaterialTheme.colors.onBackground, fontSize = 18.sp, fontWeight = FontWeight.W600 ) - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(8.dp)) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceAround @@ -44,19 +50,18 @@ fun ArticleShort( painter = painterResource(id = R.drawable.rating), contentDescription = null, modifier = Modifier.size(18.dp), - ) Spacer(modifier = Modifier.width(2.dp)) if (article.statistics.score > 0) { Text( text = '+' + article.statistics.score.toString(), - color = RatingPositive + color = RatingPositiveColor, ) } else { if (article.statistics.score < 0) { Text( text = article.statistics.score.toString(), - color = RatingNegative + color = RatingNegativeColor ) } else { Text( @@ -69,7 +74,7 @@ fun ArticleShort( Icon( painter = painterResource(id = R.drawable.views_icon), contentDescription = null, - modifier = Modifier.size(18.dp) + modifier = Modifier.size(18.dp), ) Spacer(modifier = Modifier.width(2.dp)) Text( @@ -80,13 +85,13 @@ fun ArticleShort( Icon( painter = painterResource(id = R.drawable.comments_icon), contentDescription = null, - modifier = Modifier.size(18.dp) + modifier = Modifier.size(18.dp), ) Spacer(modifier = Modifier.width(2.dp)) Text( text = formatLongNumbers(article.statistics.commentsCount), overflow = TextOverflow.Clip, - maxLines = 1 + maxLines = 1, ) } diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/screens/article/HubsRow.kt b/app/src/main/java/com/garnegsoft/hubs/ui/screens/article/HubsRow.kt index 68620988..75ef5b2d 100644 --- a/app/src/main/java/com/garnegsoft/hubs/ui/screens/article/HubsRow.kt +++ b/app/src/main/java/com/garnegsoft/hubs/ui/screens/article/HubsRow.kt @@ -1,19 +1,24 @@ package com.garnegsoft.hubs.ui.screens.article 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.padding import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.garnegsoft.hubs.api.article.Article +import com.garnegsoft.hubs.ui.theme.HubSubscribedColor @OptIn(ExperimentalLayoutApi::class) @Composable @@ -22,19 +27,24 @@ fun HubsRow( onHubClicked: (alias: String) -> Unit, onCompanyClicked: (alias: String) -> Unit, ) { - FlowRow() { - hubs.forEach { - val hubTitle = - (if (it.isProfiled) it.title + "*" else it.title) + ", " + FlowRow( + horizontalArrangement = Arrangement.spacedBy(2.dp), + verticalArrangement = Arrangement.spacedBy(2.dp) + ) { + + hubs.forEachIndexed() { index, it -> + val hubTitle = remember { + (if (it.isProfiled) it.title + "*" else it.title) + if (index != hubs.lastIndex) ", " else "" + } Text( modifier = Modifier .clip(RoundedCornerShape(2.dp)) - .clickable { if (it.isCorporative) onCompanyClicked(it.alias) else onHubClicked(it.alias) } - .padding(horizontal = 2.dp), + .clickable { if (it.isCorporative) onCompanyClicked(it.alias) else onHubClicked(it.alias) }, text = hubTitle, style = TextStyle( - color = Color.Gray, + fontSize = 16.sp, + color = if (it.relatedData?.isSubscribed == true) HubSubscribedColor else Color.Gray, fontWeight = FontWeight.W500 ) ) diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/screens/article/NewParseHtml.kt b/app/src/main/java/com/garnegsoft/hubs/ui/screens/article/NewParseHtml.kt index b72e8ade..7db6d60f 100644 --- a/app/src/main/java/com/garnegsoft/hubs/ui/screens/article/NewParseHtml.kt +++ b/app/src/main/java/com/garnegsoft/hubs/ui/screens/article/NewParseHtml.kt @@ -1,14 +1,12 @@ package com.garnegsoft.hubs.ui.screens.article -import android.content.Intent -import android.net.Uri -import android.util.Log import androidx.compose.foundation.text.ClickableText import androidx.compose.foundation.text.appendInlineContent import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString @@ -204,6 +202,7 @@ fun parseChildElements( childrenComposables.add { localSpanStyle, settings -> //Text(text = thisElementCurrentText) var context = LocalContext.current + val focusManager = LocalFocusManager.current ClickableText( text = thisElementCurrentText, style = LocalTextStyle.current.copy( @@ -212,17 +211,12 @@ fun parseChildElements( ) ), onClick = { + focusManager.clearFocus(true) thisElementCurrentText.getStringAnnotations(it, it) .find { it.tag == "url" } ?.let { if (it.item.startsWith("http")) { - Log.e( - "URL Clicked", - it.item - ) - var intent = - Intent(Intent.ACTION_VIEW, Uri.parse(it.item)) - context.startActivity(intent) + handleUrl(context, it.item) } } }) @@ -253,8 +247,8 @@ fun parseChildElements( if (!currentText.text.isBlank() && isBlock) childrenComposables.add { localSpanStyle, settings -> - //Text(text = currentText) val context = LocalContext.current + val focusManager = LocalFocusManager.current ClickableText( text = currentText, style = LocalTextStyle.current.copy( @@ -263,14 +257,11 @@ fun parseChildElements( ) ), onClick = { + focusManager.clearFocus(true) currentText.getStringAnnotations(it, it).find { it.tag == "url" }?.let { if (it.item.startsWith("http")) { - Log.e( - "URL Clicked", - it.item - ) - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(it.item)) - context.startActivity(intent) + handleUrl(context, it.item) + } } }) diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/screens/article/ParseHtml.kt b/app/src/main/java/com/garnegsoft/hubs/ui/screens/article/ParseHtml.kt index cbfc0200..20eda97b 100644 --- a/app/src/main/java/com/garnegsoft/hubs/ui/screens/article/ParseHtml.kt +++ b/app/src/main/java/com/garnegsoft/hubs/ui/screens/article/ParseHtml.kt @@ -1,9 +1,11 @@ package com.garnegsoft.hubs.ui.screens.article +import android.content.Context import android.content.Intent import android.net.Uri import android.util.Log import android.webkit.WebView +import android.widget.EditText import androidx.compose.animation.animateContentSize import androidx.compose.foundation.* import androidx.compose.foundation.layout.* @@ -18,7 +20,6 @@ import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.composed import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.draw.rotate @@ -29,6 +30,7 @@ import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextStyle @@ -43,7 +45,9 @@ import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView +import coil.ImageLoader import coil.compose.AsyncImagePainter +import com.garnegsoft.hubs.BuildConfig import com.garnegsoft.hubs.api.AsyncGifImage import com.garnegsoft.hubs.ui.common.AsyncSvgImage import com.garnegsoft.hubs.ui.theme.SecondaryColor @@ -56,657 +60,678 @@ val HEADER_FONT_WEIGHT = FontWeight.W700 val LINE_HEIGHT_FACTOR = 1.5F fun parseElement( - html: String, - spanStyle: SpanStyle, - onViewImageRequest: ((imageUrl: String) -> Unit)? = null + html: String, + spanStyle: SpanStyle, + onViewImageRequest: ((imageUrl: String) -> Unit)? = null ): Pair Unit)?> = - parseElement(Jsoup.parse(html), spanStyle, onViewImageRequest) + parseElement(Jsoup.parse(html), spanStyle, onViewImageRequest) @Stable @Composable fun RenderHtml( - html: String, - spanStyle: SpanStyle = - SpanStyle( - color = MaterialTheme.colors.onSurface, - fontSize = MaterialTheme.typography.body1.fontSize - ), - elementSettings: ElementSettings + html: String, + spanStyle: SpanStyle = + SpanStyle( + color = MaterialTheme.colors.onSurface, + fontSize = MaterialTheme.typography.body1.fontSize + ), + elementSettings: ElementSettings ) { - val result = remember { - parseElement( - html = html, - spanStyle = spanStyle - ) - } - Column { - result.first?.let { Text(it) } - result.second?.invoke(spanStyle, elementSettings) - } - + val result = remember { + parseElement( + html = html, + spanStyle = spanStyle + ) + } + val context = LocalContext.current + Column { + result.first?.let { text -> + ClickableText( + text = text, + style = LocalTextStyle.current.copy(lineHeight = LocalTextStyle.current.fontSize * 1.5f), + onClick = { + text.getStringAnnotations(it, it) + .find { it.tag == "url" } + ?.let { + if (it.item.startsWith("http")) { + handleUrl(context, it.item) + + } + } + } + ) + } + result.second?.invoke(spanStyle, elementSettings) + } + } /** * WARNING! Specify fontSize with **spanStyle** or you will get exception */ fun parseElement( - element: Element, - spanStyle: SpanStyle, - onViewImageRequest: ((imageUrl: String) -> Unit)? = null, + element: Element, + spanStyle: SpanStyle, + onViewImageRequest: ((imageUrl: String) -> Unit)? = null, ): Pair Unit)?> { - var isBlock = element.isHabrBlock() - var resultAnnotatedString: AnnotatedString = buildAnnotatedString { } - var ChildrenSpanStyle = spanStyle - - // Applying Inline elements style - when (element.tagName()) { - "del" -> ChildrenSpanStyle = ChildrenSpanStyle.copy( - textDecoration = TextDecoration.combine( - listOf( - ChildrenSpanStyle.textDecoration ?: TextDecoration.None, - TextDecoration.LineThrough - ) - ) - ) - - "b" -> ChildrenSpanStyle = ChildrenSpanStyle.copy(fontWeight = STRONG_FONT_WEIGHT) - "strong" -> ChildrenSpanStyle = ChildrenSpanStyle.copy(fontWeight = STRONG_FONT_WEIGHT) - "i" -> ChildrenSpanStyle = ChildrenSpanStyle.copy(fontStyle = FontStyle.Italic) - "em" -> { - ChildrenSpanStyle = ChildrenSpanStyle.copy(fontStyle = FontStyle.Italic) - if (element.hasClass("searched-item")) { - ChildrenSpanStyle = ChildrenSpanStyle.copy(background = Color(101, 238, 255, 76)) - } - } - - "code" -> { - ChildrenSpanStyle = ChildrenSpanStyle.copy( - fontFamily = FontFamily.Monospace, - background = Color(138, 156, 165, 20) - ) - } - - "u" -> { - ChildrenSpanStyle = ChildrenSpanStyle.copy( - textDecoration = TextDecoration.combine( - listOf( - ChildrenSpanStyle.textDecoration ?: TextDecoration.None, - TextDecoration.Underline - ) - ) - ) - } - - "s" -> { - ChildrenSpanStyle = ChildrenSpanStyle.copy( - textDecoration = TextDecoration.combine( - listOf( - ChildrenSpanStyle.textDecoration ?: TextDecoration.None, - TextDecoration.LineThrough - ) - ) - ) - } - - "sup" -> { - ChildrenSpanStyle = ChildrenSpanStyle.copy( - baselineShift = BaselineShift.Superscript, - fontSize = (ChildrenSpanStyle.fontSize.value - 4).coerceAtLeast(1f).sp - ) - } - - "sub" -> { - ChildrenSpanStyle = ChildrenSpanStyle.copy( - baselineShift = BaselineShift.Subscript, - fontSize = (ChildrenSpanStyle.fontSize.value - 4).coerceAtLeast(1f).sp - ) - } - - "br" -> { - return buildAnnotatedString { append("\n") } to null - } - - "a" -> { - ChildrenSpanStyle = ChildrenSpanStyle.copy(color = Color(88, 132, 185, 255)) - if (element.hasClass("user_link")) { - resultAnnotatedString = buildAnnotatedString { - withStyle(ChildrenSpanStyle) { append("@") } - } - } - } - - "h1" -> { - ChildrenSpanStyle = ChildrenSpanStyle.copy( - fontSize = (ChildrenSpanStyle.fontSize.value + 4f).sp, - fontWeight = HEADER_FONT_WEIGHT - ) - } - - "h2" -> { - ChildrenSpanStyle = ChildrenSpanStyle.copy( - fontSize = (ChildrenSpanStyle.fontSize.value + 3f).sp, - fontWeight = HEADER_FONT_WEIGHT - ) - } - - "h3" -> { - ChildrenSpanStyle = ChildrenSpanStyle.copy( - fontSize = (ChildrenSpanStyle.fontSize.value + 2f).sp, - fontWeight = HEADER_FONT_WEIGHT - ) - } - - "h4" -> { - ChildrenSpanStyle = ChildrenSpanStyle.copy( - fontSize = (ChildrenSpanStyle.fontSize.value + 2f).sp, - fontWeight = HEADER_FONT_WEIGHT - ) - } - - "h5" -> { - ChildrenSpanStyle = ChildrenSpanStyle.copy( - fontSize = (ChildrenSpanStyle.fontSize.value + 1f).sp, - fontWeight = HEADER_FONT_WEIGHT - ) - } - - "h6" -> { - ChildrenSpanStyle = ChildrenSpanStyle.copy( - fontWeight = HEADER_FONT_WEIGHT - ) - } - - "figcaption" -> { - ChildrenSpanStyle = - ChildrenSpanStyle.copy( - color = Color.Gray, - // TODO: Fix unspecified span style's fontSize that leads to NaN and exception - fontSize = (ChildrenSpanStyle.fontSize.value - 4).sp - ) - - } - - "img" -> { - if (element.attr("inline") == "true") { - resultAnnotatedString = buildAnnotatedString { - appendInlineContent("inlineImage_") - } - } - } - - "summary" -> return buildAnnotatedString { } to null - } - - // Child elements parsing and styling - var childrenElementsResult: ArrayList Unit)?>> = - ArrayList() - element.children().forEach { - childrenElementsResult.add(parseElement(it, ChildrenSpanStyle, onViewImageRequest)) - } - var mainComposable: (@Composable (SpanStyle, ElementSettings) -> Unit)? = null - - var childrenComposables: ArrayList<@Composable (SpanStyle, ElementSettings) -> Unit> = ArrayList() - - - // Text parsing and styling + validating children element - var currentText = buildAnnotatedString { } - var childElementsIndex = 0 - - element.childNodes().forEach { thisNode -> - if (thisNode is TextNode) { - if (!thisNode.isBlank) - currentText += - buildAnnotatedString { - withStyle(ChildrenSpanStyle) { - append( - if (thisNode.previousSibling() == null || - thisNode.previousSibling() is Element && - (thisNode.previousSibling() as Element)?.tagName() == "br" - ) - thisNode.text().trimStart() - else - thisNode.text() - ) - } - } - } - if (thisNode is Element) { - - if (childrenElementsResult[childElementsIndex].first != null) - currentText += childrenElementsResult[childElementsIndex].first!! - - - if (childrenElementsResult[childElementsIndex].second != null) { - if (currentText.isNotEmpty() && thisNode.previousElementSibling() != null - && thisNode.previousElementSibling()!!.tagName() != "pre" - ) { - var thisElementCurrentText = currentText - childrenComposables.add { localSpanStyle, settings -> - //Text(text = thisElementCurrentText) - var context = LocalContext.current - ClickableText( - text = thisElementCurrentText, - style = LocalTextStyle.current.copy( - lineHeight = localSpanStyle.fontSize.times( - LINE_HEIGHT_FACTOR - ), - color = localSpanStyle.color - ), - onClick = { - thisElementCurrentText.getStringAnnotations(it, it) - .find { it.tag == "url" } - ?.let { - if (it.item.startsWith("http")) { - Log.e( - "URL Clicked", - it.item - ) - var intent = - Intent(Intent.ACTION_VIEW, Uri.parse(it.item)) - context.startActivity(intent) - } - } - } - ) - } - } - childrenComposables.add(childrenElementsResult[childElementsIndex].second!!) - currentText = buildAnnotatedString { } - } - - - // if node is block element, break the currentText annotated string and place Text() Composable - childElementsIndex++ - } - - - } - if (!currentText.text.isBlank() && !isBlock) { - if (element.tagName() == "a") { - resultAnnotatedString += buildAnnotatedString { - var urlAnnotationId = pushStringAnnotation("url", element.attr("href")) - append(currentText) - pop(urlAnnotationId) - } - } else - resultAnnotatedString += currentText - } - - if (!currentText.text.isBlank() && isBlock) - - childrenComposables.add { localSpanStyle, settings -> - //Text(text = currentText) - val context = LocalContext.current - val style = TextStyle( - fontSize = localSpanStyle.fontSize, - lineHeight = localSpanStyle.fontSize.times( - LINE_HEIGHT_FACTOR - ), - color = localSpanStyle.color - ) - ClickableText( - text = currentText, - style = style, - onClick = { - currentText.getStringAnnotations(it, it).find { it.tag == "url" }?.let { - if (it.item.startsWith("http")) { - Log.e( - "URL Clicked", - it.item - ) - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(it.item)) - context.startActivity(intent) - } - } - } - ) - } - - // Fetching composable - mainComposable = when (element.tagName()) { - "h2" -> { localSpanStyle, settings -> - Column(Modifier.padding(top = 4.dp, bottom = 8.dp)) { - childrenComposables.forEach { it(localSpanStyle, settings) } - } - } - - "h3" -> { localSpanStyle, settings -> - Column(Modifier.padding(top = 4.dp, bottom = 6.dp)) { - childrenComposables.forEach { it(localSpanStyle, settings) } - } - } - - "h4" -> { localSpanStyle, settings -> - Column(Modifier.padding(top = 4.dp, bottom = 4.dp)) { - childrenComposables.forEach { it(localSpanStyle, settings) } - } - } - - "h5" -> { localSpanStyle, settings -> - Column(Modifier.padding(top = 4.dp, bottom = 3.dp)) { - childrenComposables.forEach { it(localSpanStyle, settings) } - } - } - - "p" -> if (element.html().isNotEmpty()) { localSpanStyle, settings -> - Column(Modifier.padding(bottom = 16.dp)) { - childrenComposables.forEach { - it(localSpanStyle, settings) - } - } - } - else null - - "a" -> if (element.hasClass("anchor")) - { localSpanStyle, settings -> } else null - - "figcaption" -> if (element.text().isNotEmpty()) - { localSpanStyle, settings -> - val context = LocalContext.current - ClickableText( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 14.dp), - text = currentText, - style = LocalTextStyle.current.copy( - lineHeight = ChildrenSpanStyle.fontSize.times(LINE_HEIGHT_FACTOR), - textAlign = TextAlign.Center - ), - onClick = { - currentText.getStringAnnotations(it, it).find { it.tag == "url" }?.let { - if (it.item.startsWith("http")) { - Log.e( - "URL Clicked", - it.item - ) - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(it.item)) - context.startActivity(intent) - } - } - }) + var isBlock = element.isHabrBlock() + var resultAnnotatedString: AnnotatedString = buildAnnotatedString { } + var ChildrenSpanStyle = spanStyle + + // Applying Inline elements style + when (element.tagName()) { + "del" -> ChildrenSpanStyle = ChildrenSpanStyle.copy( + textDecoration = TextDecoration.combine( + listOf( + ChildrenSpanStyle.textDecoration ?: TextDecoration.None, + TextDecoration.LineThrough + ) + ) + ) + + "b" -> ChildrenSpanStyle = ChildrenSpanStyle.copy(fontWeight = STRONG_FONT_WEIGHT) + "strong" -> ChildrenSpanStyle = ChildrenSpanStyle.copy(fontWeight = STRONG_FONT_WEIGHT) + "i" -> ChildrenSpanStyle = ChildrenSpanStyle.copy(fontStyle = FontStyle.Italic) + "em" -> { + ChildrenSpanStyle = ChildrenSpanStyle.copy(fontStyle = FontStyle.Italic) + if (element.hasClass("searched-item")) { + ChildrenSpanStyle = ChildrenSpanStyle.copy(background = Color(101, 238, 255, 76)) + } + } + + "code" -> { + ChildrenSpanStyle = ChildrenSpanStyle.copy( + fontFamily = FontFamily.Monospace, + background = Color(138, 156, 165, 20) + ) + } + + "u" -> { + ChildrenSpanStyle = ChildrenSpanStyle.copy( + textDecoration = TextDecoration.combine( + listOf( + ChildrenSpanStyle.textDecoration ?: TextDecoration.None, + TextDecoration.Underline + ) + ) + ) + } + + "s" -> { + ChildrenSpanStyle = ChildrenSpanStyle.copy( + textDecoration = TextDecoration.combine( + listOf( + ChildrenSpanStyle.textDecoration ?: TextDecoration.None, + TextDecoration.LineThrough + ) + ) + ) + } + + "sup" -> { + ChildrenSpanStyle = ChildrenSpanStyle.copy( + baselineShift = BaselineShift.Superscript, + fontSize = (ChildrenSpanStyle.fontSize.value - 4).coerceAtLeast(1f).sp + ) + } + + "sub" -> { + ChildrenSpanStyle = ChildrenSpanStyle.copy( + baselineShift = BaselineShift.Subscript, + fontSize = (ChildrenSpanStyle.fontSize.value - 4).coerceAtLeast(1f).sp + ) + } + + "br" -> { + return buildAnnotatedString { append("\n") } to null + } + + "a" -> { + ChildrenSpanStyle = ChildrenSpanStyle.copy(color = Color(88, 132, 185, 255)) + if (element.hasClass("user_link")) { + resultAnnotatedString = buildAnnotatedString { + withStyle(ChildrenSpanStyle) { append("@") } + } + } + } + + "h1" -> { + ChildrenSpanStyle = ChildrenSpanStyle.copy( + fontSize = (ChildrenSpanStyle.fontSize.value + 4f).sp, + fontWeight = HEADER_FONT_WEIGHT + ) + } + + "h2" -> { + ChildrenSpanStyle = ChildrenSpanStyle.copy( + fontSize = (ChildrenSpanStyle.fontSize.value + 3f).sp, + fontWeight = HEADER_FONT_WEIGHT + ) + } + + "h3" -> { + ChildrenSpanStyle = ChildrenSpanStyle.copy( + fontSize = (ChildrenSpanStyle.fontSize.value + 2f).sp, + fontWeight = HEADER_FONT_WEIGHT + ) + } + + "h4" -> { + ChildrenSpanStyle = ChildrenSpanStyle.copy( + fontSize = (ChildrenSpanStyle.fontSize.value + 2f).sp, + fontWeight = HEADER_FONT_WEIGHT + ) + } + + "h5" -> { + ChildrenSpanStyle = ChildrenSpanStyle.copy( + fontSize = (ChildrenSpanStyle.fontSize.value + 1f).sp, + fontWeight = HEADER_FONT_WEIGHT + ) + } + + "h6" -> { + ChildrenSpanStyle = ChildrenSpanStyle.copy( + fontWeight = HEADER_FONT_WEIGHT + ) + } + + "figcaption" -> { + ChildrenSpanStyle = + ChildrenSpanStyle.copy( + color = Color.Gray, + // TODO: Fix unspecified span style's fontSize that leads to NaN and exception + fontSize = (ChildrenSpanStyle.fontSize.value - 4).sp + ) + + } + + "img" -> { + if (element.attr("inline") == "true") { + resultAnnotatedString = buildAnnotatedString { + appendInlineContent("inlineImage_") + } + } + } + + "summary" -> return buildAnnotatedString { } to null + } + + // Child elements parsing and styling + var childrenElementsResult: ArrayList Unit)?>> = + ArrayList() + element.children().forEach { + childrenElementsResult.add(parseElement(it, ChildrenSpanStyle, onViewImageRequest)) + } + var mainComposable: (@Composable (SpanStyle, ElementSettings) -> Unit)? = null + + var childrenComposables: ArrayList<@Composable (SpanStyle, ElementSettings) -> Unit> = + ArrayList() + + + // Text parsing and styling + validating children element + var currentText = buildAnnotatedString { } + var childElementsIndex = 0 + + element.childNodes().forEach { thisNode -> + if (thisNode is TextNode) { + if (!thisNode.isBlank) + currentText += + buildAnnotatedString { + withStyle(ChildrenSpanStyle) { + append( + if (thisNode.previousSibling() == null || + thisNode.previousSibling() is Element && + (thisNode.previousSibling() as Element)?.tagName() == "br" + ) + thisNode.text().trimStart() + else + thisNode.text() + ) + } + } + } + if (thisNode is Element) { + + if (childrenElementsResult[childElementsIndex].first != null) + currentText += childrenElementsResult[childElementsIndex].first!! + + + if (childrenElementsResult[childElementsIndex].second != null) { + if (currentText.isNotEmpty() && thisNode.previousElementSibling() != null + && thisNode.previousElementSibling()!!.tagName() != "pre" + ) { + var thisElementCurrentText = currentText + childrenComposables.add { localSpanStyle, settings -> + //Text(text = thisElementCurrentText) + var context = LocalContext.current + val focusManager = LocalFocusManager.current + ClickableText( + text = thisElementCurrentText, + style = LocalTextStyle.current.copy( + lineHeight = localSpanStyle.fontSize.times( + LINE_HEIGHT_FACTOR + ), + color = localSpanStyle.color + ), + onClick = { + focusManager.clearFocus(true) + thisElementCurrentText.getStringAnnotations(it, it) + .find { it.tag == "url" } + ?.let { + if (it.item.startsWith("http")) { + handleUrl(context, it.item) + + } + } + } + ) + } + } + childrenComposables.add(childrenElementsResult[childElementsIndex].second!!) + currentText = buildAnnotatedString { } + } + + + // if node is block element, break the currentText annotated string and place Text() Composable + childElementsIndex++ + } + + + } + if (!currentText.text.isBlank() && !isBlock) { + if (element.tagName() == "a") { + resultAnnotatedString += buildAnnotatedString { + val url = if (element.hasClass("mention")){ + "https://habr.com${element.attr("href")}" + } else { + element.attr("href") + } + + var urlAnnotationId = pushStringAnnotation("url", url) + append(currentText) + pop(urlAnnotationId) + } + } else { + resultAnnotatedString += currentText + } + } + + if (!currentText.text.isBlank() && isBlock) + + childrenComposables.add { localSpanStyle, settings -> + //Text(text = currentText) + val context = LocalContext.current + val style = TextStyle( + fontSize = localSpanStyle.fontSize, + lineHeight = localSpanStyle.fontSize.times( + LINE_HEIGHT_FACTOR + ), + color = localSpanStyle.color + ) + val focusManager = LocalFocusManager.current + ClickableText( + text = currentText, + style = style, + onClick = { + focusManager.clearFocus(true) + currentText.getStringAnnotations(it, it).find { it.tag == "url" }?.let { + if (it.item.startsWith("http")) { + handleUrl(context, it.item) + } + } + } + ) + } + + // Fetching composable + mainComposable = when (element.tagName()) { + "h2" -> { localSpanStyle, settings -> + Column(Modifier.padding(top = 4.dp, bottom = 8.dp)) { + childrenComposables.forEach { it(localSpanStyle, settings) } + } + } + + "h3" -> { localSpanStyle, settings -> + Column(Modifier.padding(top = 4.dp, bottom = 6.dp)) { + childrenComposables.forEach { it(localSpanStyle, settings) } + } + } + + "h4" -> { localSpanStyle, settings -> + Column(Modifier.padding(top = 4.dp, bottom = 4.dp)) { + childrenComposables.forEach { it(localSpanStyle, settings) } + } + } + + "h5" -> { localSpanStyle, settings -> + Column(Modifier.padding(top = 4.dp, bottom = 3.dp)) { + childrenComposables.forEach { it(localSpanStyle, settings) } + } + } + + "p" -> if (element.html().isNotEmpty()) { localSpanStyle, settings -> + Column(Modifier.padding(bottom = 16.dp)) { + childrenComposables.forEach { + it(localSpanStyle, settings) + } + } + } + else null + + "a" -> if (element.hasClass("anchor")) + { localSpanStyle, settings -> } else null + + "figcaption" -> if (element.text().isNotEmpty()) + { localSpanStyle, settings -> + val context = LocalContext.current + val focusManager = LocalFocusManager.current + ClickableText( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 14.dp), + text = currentText, + style = LocalTextStyle.current.copy( + lineHeight = ChildrenSpanStyle.fontSize.times(LINE_HEIGHT_FACTOR), + textAlign = TextAlign.Center + ), + onClick = { + focusManager.clearFocus(true) + currentText.getStringAnnotations(it, it).find { it.tag == "url" }?.let { + if (it.item.startsWith("http")) { + handleUrl(context, it.item) + } + } + }) // Column(Modifier.padding(bottom = 12.dp)) { // childrenComposables.forEach { it(localSpanStyle) } // } - } - else null - - "img" -> if (element.hasClass("formula")) { - { localSpanStyle, settings -> - Box( - modifier = Modifier - .height(50.dp) - .fillMaxWidth() - ) { - AsyncSvgImage( - modifier = Modifier.align(Alignment.Center), - data = element.attr("src"), - contentScale = ContentScale.Inside - ) - } - - } - } else { - { it: SpanStyle, settings -> - val sourceUrl = remember { - if (element.hasAttr("data-src")) { - element.attr("data-src") - } else { - element.attr("src") - } - } - var isLoaded by rememberSaveable { mutableStateOf(false) } - var aspectRatio by rememberSaveable { - mutableStateOf(16f / 9f) - } - AsyncGifImage( - model = sourceUrl, - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 8.dp) - .clip(RoundedCornerShape(4.dp)) - .clickable(enabled = onViewImageRequest != null) { - onViewImageRequest?.invoke(sourceUrl) - } - .background( - if (!MaterialTheme.colors.isLight) MaterialTheme.colors.onBackground.copy( - 0.75f - ) else Color.Transparent - ) - .aspectRatio(aspectRatio), - contentScale = ContentScale.FillWidth, - onState = { - if (!isLoaded && it is AsyncImagePainter.State.Success) { - isLoaded = true - it.painter.intrinsicSize.let { - Log.e("painter_bounds", it.toString()) - aspectRatio = it.width / it.height - } - - } - } - ) - } - } - - "div" -> if (element.hasClass("tm-iframe_temp")) - { localSpanStyle, settings -> - - AndroidView(modifier = Modifier - .fillMaxWidth() - .aspectRatio(16f / 9f) - .padding(vertical = 4.dp) - .clip(RoundedCornerShape(4.dp)), - factory = { - WebView(it).apply { - - this.settings.javaScriptEnabled = true - this.settings.databaseEnabled = true - isFocusable = true - isLongClickable = true - loadUrl(element.attr("data-src")) - } - }) - } - else - { localSpanStyle, settings -> - Column() { - //Text(text = element.ownText()) - childrenComposables.forEach { - it(localSpanStyle, settings) - } - } - } - - "code" -> if (element.parent() != null && element.parent()!! - .tagName() == "pre" - ) { localSpanStyle, settings -> - Box(Modifier.padding(bottom = 4.dp)) { - Code( - code = element.text(), - language = LanguagesMap.getOrElse( - element.attr("class"), - { element.attr("class") }), - spanStyle = localSpanStyle, - elementSettings = settings - ) - } - resultAnnotatedString = buildAnnotatedString { } - } else - null - - - "ul" -> - if (element.parent() != null && element.parent()!!.tagName() == "li") - { localSpanStyle, settings -> - TextList( - modifier = Modifier.padding(bottom = 8.dp), - items = childrenComposables, - spanStyle = localSpanStyle, - ordered = false, - nested = true, - elementSettings = settings - ) - } - else - { localSpanStyle, settings -> - TextList( - modifier = Modifier.padding(bottom = 8.dp), - items = childrenComposables, - spanStyle = localSpanStyle, - ordered = false, - elementSettings = settings - ) - } - - "ol" -> if (element.hasAttr("start")) - { localSpanStyle, settings -> - TextList( - modifier = Modifier.padding(bottom = 8.dp), - items = childrenComposables, - spanStyle = localSpanStyle, - ordered = true, - startNumber = element.attr("start").toIntOrNull() ?: 1, - elementSettings = settings - ) - } - else - { localSpanStyle, settings -> - TextList( - modifier = Modifier.padding(bottom = 8.dp), - items = childrenComposables, - spanStyle = localSpanStyle, - ordered = true, - elementSettings = settings - ) - } - - "blockquote" -> { localSpanStyle, settings -> - val quoteWidth = with(LocalDensity.current) { 4.dp.toPx() } - Surface( - color = Color.Transparent, - modifier = Modifier - .padding(vertical = 8.dp) - .fillMaxWidth() - ) { - val blockQuoteColor = - if (MaterialTheme.colors.isLight) SecondaryColor else MaterialTheme.colors.onBackground.copy( - 0.75f - ) - Column(modifier = Modifier - .drawWithContent { - drawContent() - drawRoundRect( - color = blockQuoteColor, - size = Size(quoteWidth, size.height), - cornerRadius = CornerRadius(quoteWidth / 2, quoteWidth / 2) - ) - - } - .padding(start = 12.dp)) { - childrenComposables.forEach { it(localSpanStyle.copy(fontStyle = FontStyle.Italic), settings) } - } - } - - } - - "hr" -> { localSpanStyle, settings -> - Divider( - thickness = 1.dp, - modifier = Modifier.padding(horizontal = 4.dp, vertical = 8.dp) - ) - - } - - "details" -> { localSpanStyle, settings -> - var spoilerCaption = element.getElementsByTag("summary").first()?.text() ?: "Спойлер" - var showDetails by rememberSaveable { mutableStateOf(false) } - Surface( - color = if (MaterialTheme.colors.isLight) Color(0x65EBEBEB) else Color(0x803C3C3C), - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp) - .clip(RoundedCornerShape(4.dp)) - ) { - Column( - modifier = Modifier - .animateContentSize() - - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { showDetails = !showDetails } - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - tint = Color(0xFF5587A3), - modifier = Modifier - .size(18.dp) - .rotate( - if (!showDetails) { - -90f - } else { - 0f - } - ), - imageVector = Icons.Outlined.ArrowDropDown, contentDescription = "" + } + else null + + "img" -> if (element.hasClass("formula")) { + { localSpanStyle, settings -> + Box( + modifier = Modifier + .height(50.dp) + .fillMaxWidth() + ) { + AsyncSvgImage( + modifier = Modifier.align(Alignment.Center), + data = element.attr("src"), + contentScale = ContentScale.Inside + ) + } + + } + } else { + { it: SpanStyle, settings -> + val sourceUrl = remember { + if (element.hasAttr("data-src")) { + element.attr("data-src") + } else { + element.attr("src") + } + } + var isLoaded by rememberSaveable { mutableStateOf(false) } + var aspectRatio by rememberSaveable { + mutableStateOf(16f / 9f) + } + AsyncGifImage( + model = sourceUrl, + modifier = Modifier + .padding(bottom = 8.dp) + .clip(RoundedCornerShape(4.dp)) + .clickable(enabled = onViewImageRequest != null) { + onViewImageRequest?.invoke(sourceUrl) + } + .background( + if (!MaterialTheme.colors.isLight) MaterialTheme.colors.onBackground.copy( + 0.75f + ) else Color.Transparent + ) + .aspectRatio(aspectRatio), + contentScale = ContentScale.FillWidth, + onState = { + if (!isLoaded && it is AsyncImagePainter.State.Success) { + isLoaded = true + it.painter.intrinsicSize.let { + Log.e("painter_bounds", it.toString()) + aspectRatio = it.width / it.height + } + + } + } + ) + } + } + + "div" -> if (element.hasClass("tm-iframe_temp")) + { localSpanStyle, settings -> + + AndroidView(modifier = Modifier + .fillMaxWidth() + .aspectRatio(16f / 9f) + .padding(vertical = 4.dp) + .clip(RoundedCornerShape(4.dp)), + factory = { + WebView(it).apply { + + this.settings.javaScriptEnabled = true + this.settings.databaseEnabled = true + isFocusable = true + isLongClickable = true + loadUrl(element.attr("data-src")) + } + }) + } + else + { localSpanStyle, settings -> + Column() { + //Text(text = element.ownText()) + childrenComposables.forEach { + it(localSpanStyle, settings) + } + } + } + + "code" -> if (element.parent() != null && element.parent()!! + .tagName() == "pre" + ) { localSpanStyle, settings -> + Box(Modifier.padding(bottom = 4.dp)) { + DisableSelection { + Code( + code = element.text(), + language = LanguagesMap.getOrElse( + element.attr("class"), + { element.attr("class") }), + spanStyle = localSpanStyle, + elementSettings = settings + ) + } + } + resultAnnotatedString = buildAnnotatedString { } + } else + null + + + "ul" -> + if (element.parent() != null && element.parent()!!.tagName() == "li") + { localSpanStyle, settings -> + TextList( + modifier = Modifier.padding(bottom = 8.dp), + items = childrenComposables, + spanStyle = localSpanStyle, + ordered = false, + nested = true, + elementSettings = settings + ) + } + else + { localSpanStyle, settings -> + TextList( + modifier = Modifier.padding(bottom = 8.dp), + items = childrenComposables, + spanStyle = localSpanStyle, + ordered = false, + elementSettings = settings + ) + } + + "ol" -> if (element.hasAttr("start")) + { localSpanStyle, settings -> + TextList( + modifier = Modifier.padding(bottom = 8.dp), + items = childrenComposables, + spanStyle = localSpanStyle, + ordered = true, + startNumber = element.attr("start").toIntOrNull() ?: 1, + elementSettings = settings + ) + } + else + { localSpanStyle, settings -> + TextList( + modifier = Modifier.padding(bottom = 8.dp), + items = childrenComposables, + spanStyle = localSpanStyle, + ordered = true, + elementSettings = settings + ) + } + + "blockquote" -> { localSpanStyle, settings -> + val quoteWidth = with(LocalDensity.current) { 4.dp.toPx() } + Surface( + color = Color.Transparent, + modifier = Modifier + .padding(vertical = 8.dp) + .fillMaxWidth() + ) { + val blockQuoteColor = + if (MaterialTheme.colors.isLight) SecondaryColor else MaterialTheme.colors.onBackground.copy( + 0.75f + ) + Column(modifier = Modifier + .drawWithContent { + drawContent() + drawRoundRect( + color = blockQuoteColor, + size = Size(quoteWidth, size.height), + cornerRadius = CornerRadius(quoteWidth / 2, quoteWidth / 2) ) - Spacer(modifier = Modifier.width(4.dp)) - DisableSelection { - Text( - text = spoilerCaption, - color = Color(0xFF5587A3), - fontSize = localSpanStyle.fontSize - ) - } + } - if (showDetails) { - Divider() - Column( - modifier = Modifier.padding( - start = 12.dp, - end = 12.dp, - bottom = 8.dp, - top = 8.dp - ) - ) { - childrenComposables.forEach { it(localSpanStyle, settings) } - } - } - } - } - - } - - "table" -> { localSpanStyle, settings -> - val backgroundColor = - if (MaterialTheme.colors.isLight) MaterialTheme.colors.surface else MaterialTheme.colors.background - val textColor = MaterialTheme.colors.onBackground - val fontSize = - LocalDensity.current.fontScale * MaterialTheme.typography.body1.fontSize.value - AndroidView(modifier = Modifier + .padding(start = 12.dp)) { + childrenComposables.forEach { + it( + localSpanStyle.copy(fontStyle = FontStyle.Italic), + settings + ) + } + } + } + + } + + "hr" -> { localSpanStyle, settings -> + Divider( + thickness = 1.dp, + modifier = Modifier.padding(horizontal = 4.dp, vertical = 8.dp) + ) + + } + + "details" -> { localSpanStyle, settings -> + var spoilerCaption = element.getElementsByTag("summary").first()?.text() ?: "Спойлер" + var showDetails by rememberSaveable { mutableStateOf(false) } + Surface( + color = if (MaterialTheme.colors.isLight) Color(0x65EBEBEB) else Color(0x803C3C3C), + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .clip(RoundedCornerShape(4.dp)) + ) { + Column( + modifier = Modifier + .animateContentSize() + + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { showDetails = !showDetails } + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + tint = Color(0xFF5587A3), + modifier = Modifier + .size(18.dp) + .rotate( + if (!showDetails) { + -90f + } else { + 0f + } + ), + imageVector = Icons.Outlined.ArrowDropDown, contentDescription = "" + ) + Spacer(modifier = Modifier.width(4.dp)) + DisableSelection { + Text( + text = spoilerCaption, + color = Color(0xFF5587A3), + fontSize = localSpanStyle.fontSize + ) + } + } + if (showDetails) { + Divider() + Column( + modifier = Modifier.padding( + start = 12.dp, + end = 12.dp, + bottom = 8.dp, + top = 8.dp + ) + ) { + childrenComposables.forEach { it(localSpanStyle, settings) } + } + } + } + } + + } + + "table" -> { localSpanStyle, settings -> + val backgroundColor = + if (MaterialTheme.colors.isLight) MaterialTheme.colors.surface else MaterialTheme.colors.background + val textColor = MaterialTheme.colors.onBackground + val fontSize = + LocalDensity.current.fontScale * MaterialTheme.typography.body1.fontSize.value + AndroidView(modifier = Modifier // .fillMaxWidth() - .padding(vertical = 8.dp) - .clip(RoundedCornerShape(0.dp)), - factory = { - WebView(it).apply { - this.setBackgroundColor(backgroundColor.toArgb()) - this.isScrollContainer = false - isFocusable = true - isLongClickable = true - isVerticalScrollBarEnabled = false - val bodyElement = Element("body") - .attr( - "style", - "color: rgb(${textColor.red * 255f}, ${textColor.green * 255f}, ${textColor.blue * 255f}); " + + .padding(vertical = 8.dp) + .clip(RoundedCornerShape(0.dp)), + factory = { + WebView(it).apply { + this.setBackgroundColor(backgroundColor.toArgb()) + this.isScrollContainer = false + isFocusable = true + isLongClickable = true + isVerticalScrollBarEnabled = false + val bodyElement = Element("body") + .attr( + "style", + "color: rgb(${textColor.red * 255f}, ${textColor.green * 255f}, ${textColor.blue * 255f}); " + // "background-color: rgb(${backgroundColor.red * 255f}, ${backgroundColor.green * 255f}, ${backgroundColor.blue * 255f}); " + - "font-size: ${fontSize}px;" + - "margin: 0px;" - ) - .appendChild( - Element("style").appendText( - """ + "font-size: ${fontSize}px;" + + "margin: 0px;" + ) + .appendChild( + Element("style").appendText( + """ td { padding: 10px; border: 1px solid rgba(${textColor.red * 255f}, ${textColor.green * 255f}, ${textColor.blue * 255f}, 0.5); @@ -719,224 +744,242 @@ fun parseElement( width: auto; } """.trimIndent() - ) - ) - .appendChild(element) - - loadData(bodyElement.outerHtml(), "text/html; charset=utf-8", "utf-8") - } - }) - - } - - else -> if (childrenComposables.size == 0) { - null - } else { - { localSpanStyle, settings -> - Column() { - childrenComposables.forEach { it(localSpanStyle, settings) } - } - } - } - } - return resultAnnotatedString to mainComposable + ) + ) + .appendChild(element) + + loadData(bodyElement.outerHtml(), "text/html; charset=utf-8", "utf-8") + } + }) + + } + + else -> if (childrenComposables.size == 0) { + null + } else { + { localSpanStyle, settings -> + Column() { + childrenComposables.forEach { it(localSpanStyle, settings) } + } + } + } + } + return resultAnnotatedString to mainComposable } val LanguagesMap = mapOf( - "" to "Язык неизвестен", - "plaintext" to "Текст", - - "1c" to "1C", - - "assembly" to "Assembly", - - "bash" to "BASH", - - "css" to "CSS", - "cmake" to "CMake", - "cpp" to "C++", - "cs" to "C#", - - "dart" to "Dart", - "delphi" to "Delphi", - "diff" to "Diff", - "django" to "Django", - - "elixir" to "Elixir", - "erlang" to "Erlang", - - "fs" to "F#", - - "go" to "Go", - - "html" to "HTML", - - "java" to "Java", - "javascript" to "JavaScript", - "json" to "JSON", - "julia" to "Julia", - - "kotlin" to "Kotlin", - - "lisp" to "Lisp", - "lua" to "Lua", - - "markdown" to "Markdown", - "matlab" to "Matlab", - - "nginx" to "NGINX", - - "objectivec" to "Objective C", - - "perl" to "Perl", - "pgsql" to "pgSQL", - "php" to "PHP", - "powershell" to "PowerShell", - "python" to "Python", - - "r" to "R", - "ruby" to "Ruby", - "rust" to "Rust", - - "swift" to "Swift", - "sql" to "SQL", - "scala" to "Scala", - "smalltalk" to "Smalltalk", - - "typescript" to "TypeScript", - - "vala" to "Vala", - "vbscript" to "Vbscript", - "vhdl" to "VHDL", - - "xml" to "XML", - - "yaml" to "YAML", - - "zig" to "Zig" + "" to "Язык неизвестен", + "plaintext" to "Текст", + + "1c" to "1C", + + "assembly" to "Assembly", + + "bash" to "BASH", + + "css" to "CSS", + "cmake" to "CMake", + "cpp" to "C++", + "cs" to "C#", + + "dart" to "Dart", + "delphi" to "Delphi", + "diff" to "Diff", + "django" to "Django", + + "elixir" to "Elixir", + "erlang" to "Erlang", + + "fs" to "F#", + + "go" to "Go", + + "html" to "HTML", + + "java" to "Java", + "javascript" to "JavaScript", + "json" to "JSON", + "julia" to "Julia", + + "kotlin" to "Kotlin", + + "lisp" to "Lisp", + "lua" to "Lua", + + "markdown" to "Markdown", + "matlab" to "Matlab", + + "nginx" to "NGINX", + + "objectivec" to "Objective C", + + "perl" to "Perl", + "pgsql" to "pgSQL", + "php" to "PHP", + "powershell" to "PowerShell", + "python" to "Python", + + "r" to "R", + "ruby" to "Ruby", + "rust" to "Rust", + + "swift" to "Swift", + "sql" to "SQL", + "scala" to "Scala", + "smalltalk" to "Smalltalk", + + "typescript" to "TypeScript", + + "vala" to "Vala", + "vbscript" to "Vbscript", + "vhdl" to "VHDL", + + "xml" to "XML", + + "yaml" to "YAML", + + "zig" to "Zig" ) // may be redundant fun Element.isHabrBlock(): Boolean { - val blocks = arrayListOf( - "h1", "h2", "h3", "h4", "h5", "h6", - "p", "div", "img", "table", "iframe", - "li", "ul", "ol", "figcaption", "blockquote", - "hr" - ) - - blocks.forEach { - if (tagName() == it) return true - } - return false + val blocks = arrayListOf( + "h1", "h2", "h3", "h4", "h5", "h6", + "p", "div", "img", "table", "iframe", + "li", "ul", "ol", "figcaption", "blockquote", + "hr" + ) + + blocks.forEach { + if (tagName() == it) return true + } + return false } @Composable fun TextList( - modifier: Modifier = Modifier, - items: List<@Composable (SpanStyle, ElementSettings) -> Unit>, - spanStyle: SpanStyle, - elementSettings: ElementSettings, - ordered: Boolean, - nested: Boolean = false, - startNumber: Int = 1 + modifier: Modifier = Modifier, + items: List<@Composable (SpanStyle, ElementSettings) -> Unit>, + spanStyle: SpanStyle, + elementSettings: ElementSettings, + ordered: Boolean, + nested: Boolean = false, + startNumber: Int = 1 ) { - var itemNumber = startNumber - Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) { - items.forEach { - - Row() { - DisableSelection { - if (ordered) { - Text(buildAnnotatedString { withStyle(spanStyle) { append("$itemNumber.") } }) - } else - if (nested) { - Text(text = "◦", fontSize = spanStyle.fontSize) - } else { - Text(text = "•", fontSize = spanStyle.fontSize) - } - } - Spacer(modifier = Modifier.width(4.dp)) - it(spanStyle, elementSettings) - } - itemNumber++ - } - } + var itemNumber = startNumber + Column(modifier = modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) { + items.forEach { + + Row() { + DisableSelection { + if (ordered) { + Text(buildAnnotatedString { withStyle(spanStyle) { append("$itemNumber.") } }) + } else + if (nested) { + Text(text = "◦", fontSize = spanStyle.fontSize) + } else { + Text(text = "•", fontSize = spanStyle.fontSize) + } + } + Spacer(modifier = Modifier.width(4.dp)) + it(spanStyle, elementSettings) + } + itemNumber++ + } + } } const val CODE_ALPHA_VALUE = 0.035f @Composable fun Code( - code: String, - language: String, - spanStyle: SpanStyle, - elementSettings: ElementSettings, - modifier: Modifier = Modifier + code: String, + language: String, + spanStyle: SpanStyle, + elementSettings: ElementSettings, + modifier: Modifier = Modifier ) { - Column( - modifier - .clip(RoundedCornerShape(4.dp)) - ) { - Surface( - color = MaterialTheme.colors.onBackground.copy(CODE_ALPHA_VALUE), - modifier = Modifier.fillMaxWidth() - ) { - DisableSelection { - Row(modifier = Modifier.padding(8.dp)) { - Text( - text = buildAnnotatedString { withStyle(spanStyle) { append(language) } }, - fontWeight = FontWeight.W600, - fontFamily = FontFamily.SansSerif - ) - Spacer(modifier = Modifier.width(6.dp)) - } - } - - - } - Surface( - color = MaterialTheme.colors.onBackground.copy(CODE_ALPHA_VALUE), - modifier = Modifier.fillMaxWidth() - ) { - Row() { - Surface( - color = MaterialTheme.colors.onBackground.copy(0f), - ) { - Column(Modifier.padding(8.dp)) { - var linesIndicator = String() - for (i in 1..code.count { it == "\n"[0] } + 1) { - linesIndicator += "$i\n" - } - linesIndicator = linesIndicator.take(linesIndicator.length - 1) - - DisableSelection { - Text( - text = buildAnnotatedString { withStyle(spanStyle.copy(color = MaterialTheme.colors.onBackground.copy(0.5f))) { append(linesIndicator) } }, - color = MaterialTheme.colors.onBackground.copy(0.5f), - fontFamily = FontFamily.Monospace, - textAlign = TextAlign.End - ) - } - } - } - Row( - modifier = Modifier - .horizontalScroll(rememberScrollState()) - .fillMaxWidth() - .padding(8.dp) - ) { - Text( - text = buildAnnotatedString { withStyle(spanStyle) { append(code) } }, + Column(modifier.clip(RoundedCornerShape(4.dp))) { + Surface( + color = MaterialTheme.colors.onBackground.copy(CODE_ALPHA_VALUE), + modifier = Modifier.fillMaxWidth() + ) { + DisableSelection { + Row(modifier = Modifier.padding(8.dp)) { + Text( + text = buildAnnotatedString { withStyle(spanStyle) { append(language) } }, + fontWeight = FontWeight.W600, + fontFamily = FontFamily.SansSerif + ) + Spacer(modifier = Modifier.width(6.dp)) + } + } + + + } + Surface( + color = MaterialTheme.colors.onBackground.copy(CODE_ALPHA_VALUE), + modifier = Modifier.fillMaxWidth() + ) { + Row() { + Surface( + color = MaterialTheme.colors.onBackground.copy(0f), + ) { + Column(Modifier.padding(8.dp)) { + var linesIndicator = String() + for (i in 1..code.count { it == "\n"[0] } + 1) { + linesIndicator += "$i\n" + } + linesIndicator = linesIndicator.take(linesIndicator.length - 1) + + DisableSelection { + Text( + text = buildAnnotatedString { + withStyle( + spanStyle.copy( + color = MaterialTheme.colors.onBackground.copy( + 0.5f + ) + ) + ) { append(linesIndicator) } + }, + color = MaterialTheme.colors.onBackground.copy(0.5f), + fontFamily = FontFamily.Monospace, + textAlign = TextAlign.End + ) + } + } + } + Row( + modifier = Modifier + .horizontalScroll(rememberScrollState()) + .fillMaxWidth() + .padding(8.dp) + ) { + Text( + text = buildAnnotatedString { withStyle(spanStyle) { append(code) } }, + + fontFamily = FontFamily.Monospace, + ) + } + } + } + } +} - fontFamily = FontFamily.Monospace, - ) - } - } - } - } +fun handleUrl(context: Context, url: String) { + Log.e( + "URL Clicked", + url + ) + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)).apply { + if (url.startsWith("https://habr.com")){ + this.`package` = BuildConfig.APPLICATION_ID + } + } + context.startActivity(intent) } diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/screens/comments/CommentItem.kt b/app/src/main/java/com/garnegsoft/hubs/ui/screens/comments/CommentItem.kt index 53c4a16f..ff07ae2a 100644 --- a/app/src/main/java/com/garnegsoft/hubs/ui/screens/comments/CommentItem.kt +++ b/app/src/main/java/com/garnegsoft/hubs/ui/screens/comments/CommentItem.kt @@ -1,6 +1,5 @@ package com.garnegsoft.hubs.ui.screens.comments -import android.util.Log import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.BorderStroke @@ -31,8 +30,8 @@ import coil.compose.AsyncImage import com.garnegsoft.hubs.R import com.garnegsoft.hubs.api.comment.Comment import com.garnegsoft.hubs.api.utils.placeholderColorLegacy -import com.garnegsoft.hubs.ui.theme.RatingNegative -import com.garnegsoft.hubs.ui.theme.RatingPositive +import com.garnegsoft.hubs.ui.theme.RatingNegativeColor +import com.garnegsoft.hubs.ui.theme.RatingPositiveColor import kotlinx.coroutines.delay import org.jsoup.Jsoup @@ -86,25 +85,32 @@ fun CommentItem( .background(MaterialTheme.colors.secondary) ) Spacer(modifier = Modifier.width(4.dp)) - Column { - Text( - text = it.author.alias, - color = MaterialTheme.colors.secondary, - fontWeight = FontWeight.W500 - ) - Text( - text = Jsoup.parse(it.message).child(0).child(1).child(0).children().firstOrNull { - Log.e("current tag", it.tagName()) - it.tagName() != "blockquote" - }?.text() ?: "", - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + if (it.deleted){ + Box(modifier = Modifier + .fillMaxWidth() + .padding(vertical = 12.dp), + contentAlignment = Alignment.CenterStart){ + Text(text = "Удаленное сообщение", color = MaterialTheme.colors.onSurface.copy(0.5f)) + } + } else { + Column { + Text( + text = it.author.alias, + color = MaterialTheme.colors.secondary, + fontWeight = FontWeight.W500 + ) + Text( + text = Jsoup.parse(it.message).text() ?: "", + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } } } Spacer(modifier = Modifier.height(8.dp)) + } @@ -276,8 +282,8 @@ fun CommentItem( "" } + comment.score, color = when { - comment.score > 0 -> RatingPositive - comment.score < 0 -> RatingNegative + comment.score > 0 -> RatingPositiveColor + comment.score < 0 -> RatingNegativeColor else -> statisticsColor } ) diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/screens/comments/CommentThreadScreen.kt b/app/src/main/java/com/garnegsoft/hubs/ui/screens/comments/CommentThreadScreen.kt index a6427cf2..bbb6be59 100644 --- a/app/src/main/java/com/garnegsoft/hubs/ui/screens/comments/CommentThreadScreen.kt +++ b/app/src/main/java/com/garnegsoft/hubs/ui/screens/comments/CommentThreadScreen.kt @@ -156,49 +156,54 @@ fun CommentsThreadScreen( items( items = collection.comments.toList() ) { - CommentItem( - modifier = Modifier - .padding( - start = 20.dp.times(it.level.coerceAtMost(5)) - ), - comment = it, - highlight = false, - showReplyButton = collection.commentAccess.canComment, - onAuthorClick = { onAuthor(it.author.alias) }, - onShare = { - val intent = Intent(Intent.ACTION_SEND) - intent.putExtra( - Intent.EXTRA_TEXT, - "https://habr.com/p/${articleId}/comments/#comment_${it.id}" - ) - intent.setType("text/plain") - context.startActivity(Intent.createChooser(intent, null)) - }, - onReplyClick = { - parentComment = it - } - ) { - Column { - it.let { - SelectionContainer { - ((parseElement( - it.message, SpanStyle( - fontSize = 16.sp, - color = MaterialTheme.colors.onSurface - ), - onViewImageRequest = onImageClick - ).second)?.let { it1 -> - it1( - SpanStyle( + if (it.deleted){ + DeletedCommentItem(modifier = Modifier.padding(start = 20.dp.times(it.level.coerceAtMost(5)))) + } + else { + CommentItem( + modifier = Modifier + .padding( + start = 20.dp.times(it.level.coerceAtMost(5)) + ), + comment = it, + highlight = false, + showReplyButton = collection.commentAccess.canComment, + onAuthorClick = { onAuthor(it.author.alias) }, + onShare = { + val intent = Intent(Intent.ACTION_SEND) + intent.putExtra( + Intent.EXTRA_TEXT, + "https://habr.com/p/${articleId}/comments/#comment_${it.id}" + ) + intent.setType("text/plain") + context.startActivity(Intent.createChooser(intent, null)) + }, + onReplyClick = { + parentComment = it + } + ) { + Column { + it.let { + SelectionContainer { + ((parseElement( + it.message, SpanStyle( fontSize = 16.sp, color = MaterialTheme.colors.onSurface ), - elementsSettings - ) - }) + onViewImageRequest = onImageClick + ).second)?.let { it1 -> + it1( + SpanStyle( + fontSize = 16.sp, + color = MaterialTheme.colors.onSurface + ), + elementsSettings + ) + }) + } } + } - } } } diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/screens/comments/CommentsScreen.kt b/app/src/main/java/com/garnegsoft/hubs/ui/screens/comments/CommentsScreen.kt index 5e493fc4..7c987d71 100644 --- a/app/src/main/java/com/garnegsoft/hubs/ui/screens/comments/CommentsScreen.kt +++ b/app/src/main/java/com/garnegsoft/hubs/ui/screens/comments/CommentsScreen.kt @@ -19,6 +19,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowForward import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.sharp.KeyboardArrowDown import androidx.compose.runtime.* import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.saveable.rememberSaveable @@ -30,9 +31,11 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.boundsInRoot import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.MutableLiveData @@ -40,21 +43,25 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelStoreOwner import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel +import coil.ImageLoader import coil.compose.AsyncImage +import com.garnegsoft.hubs.api.HabrApi import com.garnegsoft.hubs.api.article.list.ArticleSnippet import com.garnegsoft.hubs.api.comment.Comment import com.garnegsoft.hubs.api.comment.CommentsCollection import com.garnegsoft.hubs.api.comment.Threads import com.garnegsoft.hubs.api.comment.list.CommentsListController import com.garnegsoft.hubs.api.dataStore.HubsDataStore -import com.garnegsoft.hubs.api.dataStore.settingsDataStoreFlowWithDefault -import com.garnegsoft.hubs.ui.common.ArticleCard -import com.garnegsoft.hubs.ui.common.defaultArticleCardStyle +import com.garnegsoft.hubs.ui.common.feedCards.article.ArticleCard +import com.garnegsoft.hubs.ui.common.feedCards.article.ArticleCardStyle import com.garnegsoft.hubs.ui.screens.article.ElementSettings import com.garnegsoft.hubs.ui.screens.article.parseElement import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import okhttp3.OkHttpClient import org.jsoup.Jsoup +import kotlin.math.roundToInt class CommentsScreenViewModel : ViewModel() { @@ -106,27 +113,26 @@ fun CommentsScreen( val context = LocalContext.current - val commentsDisplayMode by context.settingsDataStoreFlowWithDefault( - HubsDataStore.Settings.Keys.Comments.CommentsDisplayMode, - HubsDataStore.Settings.Keys.Comments.CommentsDisplayModes.Default.ordinal - ).collectAsState( - initial = null - ) + val commentsDisplayMode by HubsDataStore.Settings + .getValueFlow(context, HubsDataStore.Settings.CommentsDisplayMode) + .collectAsState(initial = null) + + var returnToCommentIndex by remember { mutableStateOf(null) } commentsDisplayMode?.let { - val mode = HubsDataStore.Settings.Keys.Comments.CommentsDisplayModes.values()[it] + val mode = HubsDataStore.Settings.CommentsDisplayMode.CommentsDisplayModes.values()[it] LaunchedEffect(key1 = Unit) { if ( - (mode == HubsDataStore.Settings.Keys.Comments.CommentsDisplayModes.Threads && + (mode == HubsDataStore.Settings.CommentsDisplayMode.CommentsDisplayModes.Threads && !viewModel.threadsData.isInitialized) || - (mode == HubsDataStore.Settings.Keys.Comments.CommentsDisplayModes.Default + (mode == HubsDataStore.Settings.CommentsDisplayMode.CommentsDisplayModes.Default && !viewModel.commentsData.isInitialized) ) { launch(Dispatchers.IO) { viewModel.parentPostSnippet.postValue(ArticleController.getSnippet(parentPostId)) - if (mode == HubsDataStore.Settings.Keys.Comments.CommentsDisplayModes.Default) { + if (mode == HubsDataStore.Settings.CommentsDisplayMode.CommentsDisplayModes.Default) { CommentsListController.getComments(parentPostId)?.let { viewModel.commentsData.postValue(it) } @@ -161,8 +167,17 @@ fun CommentsScreen( } } }) + val coroutineScope = rememberCoroutineScope() + var answeringComment: Comment? by remember { + mutableStateOf(null) + } + val commentTextFieldFocusRequester = remember { FocusRequester() } + var articleHeaderOffset by remember { mutableStateOf(0f) } + + var itemOffsetCount by remember { mutableStateOf(1) } Scaffold( + modifier = Modifier.imePadding(), topBar = { TopAppBar( elevation = 0.dp, @@ -173,15 +188,114 @@ fun CommentsScreen( } } ) + }, + floatingActionButton = { + returnToCommentIndex?.let { index -> + + + FloatingActionButton( + modifier = Modifier.sizeIn(maxWidth = 52.dp, maxHeight = 52.dp), + onClick = { + coroutineScope.launch { + lazyListState.animateScrollToItem( + index + itemOffsetCount, + (-articleHeaderOffset).toInt() + ) + returnToCommentIndex = null + } + }, + content = { + Icon( + imageVector = Icons.Sharp.KeyboardArrowDown, + contentDescription = null + ) + }, + elevation = FloatingActionButtonDefaults.elevation(4.dp, 0.dp) + ) + + } + + }, + bottomBar = { + Column { + if (threadsData?.commentAccess?.canComment == true || commentsData?.commentAccess?.canComment == true) { + AnimatedVisibility( + visible = answeringComment != null, + enter = expandVertically(expandFrom = Alignment.Bottom), + exit = shrinkVertically(shrinkTowards = Alignment.Bottom) + ) { + val comment = answeringComment + Column { + Divider() + Row(modifier = Modifier + .clickable { + val index = + commentsData?.comments?.indexOf(answeringComment) ?: 0 + coroutineScope.launch { + lazyListState.animateScrollToItem(index) + } + } + .background(MaterialTheme.colors.surface) + .padding(4.dp) + .padding(start = 4.dp) + .height(IntrinsicSize.Min), + verticalAlignment = Alignment.CenterVertically + ) { + + Spacer( + modifier = Modifier + .width(4.dp) + .fillMaxHeight() + .clip(CircleShape) + .background(MaterialTheme.colors.secondary) + ) + Spacer(modifier = Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = comment?.author?.alias ?: "", + fontWeight = FontWeight.W500, + color = MaterialTheme.colors.primary.copy(0.9f) + ) + val text = comment?.message ?: "" + Text( + maxLines = 1, + text = Jsoup.parse(text).text(), + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.body2, + color = MaterialTheme.colors.onSurface.copy(0.6f) + ) + + } + Spacer(modifier = Modifier.width(4.dp)) + IconButton(onClick = { answeringComment = null }) { + Icon( + imageVector = Icons.Outlined.Close, + contentDescription = "", + tint = MaterialTheme.colors.secondary + ) + + } + } + } + } + Divider() + EnterCommentTextField( + focusRequester = commentTextFieldFocusRequester, + onSend = { + viewModel.comment( + text = it, + postId = parentPostId, + parentCommentId = answeringComment?.id + ) + commentTextFieldFocusRequester.freeFocus() + }) + } + } } ) { - var parentComment: Comment? by remember { - mutableStateOf(null) - } + val showArticleHeader by remember { derivedStateOf { lazyListState.firstVisibleItemIndex > 0 } } - val commentTextFieldFocusRequester = remember { FocusRequester() } val randomCoroutineScope = rememberCoroutineScope() - var articleHeaderOffset by remember { mutableStateOf(0f) } val elementsSettings = remember { ElementSettings( fontSize = 16.sp, @@ -189,31 +303,47 @@ fun CommentsScreen( fitScreenWidth = false ) } + + LaunchedEffect(key1 = remember { derivedStateOf { lazyListState.firstVisibleItemIndex } }, block = { + returnToCommentIndex?.let { + if (lazyListState.firstVisibleItemIndex >= it + itemOffsetCount) { + returnToCommentIndex = null + } + } + }) + Box { Column( modifier = Modifier .padding(it) .imePadding() ) { + val articleCardStyle = + ArticleCardStyle.defaultArticleCardStyle()?.copy( + showImage = false, + showTextSnippet = false, + bookmarksButtonAllowedBeEnabled = articleSnippet?.relatedData != null + ) LazyColumn( state = lazyListState, modifier = Modifier.weight(1f), contentPadding = PaddingValues(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { + if (articleSnippet != null) { item { - ArticleCard( - article = articleSnippet, - onClick = onArticleClicked, - style = defaultArticleCardStyle().copy( - showImage = false, - showTextSnippet = false, - addToBookmarksButtonEnabled = articleSnippet.relatedData != null - ), - onAuthorClick = { onUserClicked(articleSnippet.author!!.alias) }, - onCommentsClick = { } - ) + + articleCardStyle?.let { + ArticleCard( + article = articleSnippet, + onClick = onArticleClicked, + style = it, + onAuthorClick = { onUserClicked(articleSnippet.author!!.alias) }, + onCommentsClick = { } + ) + } + } } @@ -222,8 +352,6 @@ fun CommentsScreen( items = threadsData.threads, key = { index, it -> it.root.id } ) { index, it -> - val context = LocalContext.current - Column(horizontalAlignment = Alignment.End) { CommentItem( @@ -247,7 +375,7 @@ fun CommentsScreen( ) ) }, - onReplyClick = { } + onReplyClick = {} ) { Column { it.root.let { @@ -299,60 +427,84 @@ fun CommentsScreen( items = commentsData!!.comments, key = { index, it -> it.id } ) { index, it -> - Column(horizontalAlignment = Alignment.End) { - - CommentItem( - modifier = Modifier - .padding(start = 20.dp * it.level.coerceAtMost(5)), - comment = it, - onAuthorClick = { onUserClicked(it.author.alias) }, - parentComment = commentsData!!.comments.firstOrNull { com -> com.id == it.parentCommentId }, - highlight = it.id == commentId, - showReplyButton = commentsData!!.commentAccess.canComment, - onShare = { - val intent = Intent(Intent.ACTION_SEND) - intent.putExtra( - Intent.EXTRA_TEXT, - "https://habr.com/p/${parentPostId}/comments/#comment_${it.id}" - ) - intent.setType("text/plain") - context.startActivity( - Intent.createChooser( - intent, - null + val parentComment = remember { + commentsData!!.comments.firstOrNull { com -> com.id == it.parentCommentId } + } + val parentCommentIndex = remember { + parentComment?.let { + return@remember commentsData!!.comments.indexOf(it) + } ?: 0 + } + if (it.deleted) { + DeletedCommentItem( + modifier = Modifier.padding( + start = 20.dp * it.level.coerceAtMost( + 5 ) ) - }, - onReplyClick = { - parentComment = it - } - ) { - Column { - it.let { - SelectionContainer { - ((parseElement( - it.message, SpanStyle( - fontSize = 16.sp, - color = MaterialTheme.colors.onSurface - ), - onViewImageRequest = onImageClick - ).second)?.let { it1 -> - it1( - SpanStyle( + ) + } else { + CommentItem( + modifier = Modifier + .padding(start = 20.dp * it.level.coerceAtMost(5)), + comment = it, + onAuthorClick = { onUserClicked(it.author.alias) }, + parentComment = parentComment, + highlight = it.id == commentId, + showReplyButton = commentsData!!.commentAccess.canComment, + onShare = { + val intent = Intent(Intent.ACTION_SEND) + intent.putExtra( + Intent.EXTRA_TEXT, + "https://habr.com/p/${parentPostId}/comments/#comment_${it.id}" + ) + intent.setType("text/plain") + context.startActivity( + Intent.createChooser( + intent, + null + ) + ) + }, + onReplyClick = { + answeringComment = it + commentTextFieldFocusRequester.requestFocus() + }, + onParentCommentSnippetClick = { + coroutineScope.launch(Dispatchers.Main) { + returnToCommentIndex = index + lazyListState.animateScrollToItem( + parentCommentIndex + 1, + -articleHeaderOffset.roundToInt() + ) + } + } + ) { + Column { + it.let { + SelectionContainer { + ((parseElement( + it.message, SpanStyle( fontSize = 16.sp, color = MaterialTheme.colors.onSurface ), - elementsSettings - ) - }) + onViewImageRequest = onImageClick + ).second)?.let { it1 -> + it1( + SpanStyle( + fontSize = 16.sp, + color = MaterialTheme.colors.onSurface + ), + elementsSettings + ) + }) + } } } } } } - - } } else { item { @@ -366,79 +518,6 @@ fun CommentsScreen( } } - if (threadsData?.commentAccess?.canComment == true || commentsData?.commentAccess?.canComment == true) { - AnimatedVisibility( - visible = if (parentComment != null) true else false, - enter = expandVertically(expandFrom = Alignment.Bottom), - exit = shrinkVertically(shrinkTowards = Alignment.Bottom) - ) { - val comment = parentComment - Column { - Divider() - val coroutineScope = rememberCoroutineScope() - Row(modifier = Modifier - .clickable { - val index = - commentsData?.comments?.indexOf(parentComment) ?: 0 - coroutineScope.launch { - lazyListState.animateScrollToItem(index) - } - } - .background(MaterialTheme.colors.surface) - .padding(4.dp) - .padding(start = 4.dp) - .height(IntrinsicSize.Min), - verticalAlignment = Alignment.CenterVertically - ) { - - Spacer( - modifier = Modifier - .width(4.dp) - .fillMaxHeight() - .clip(CircleShape) - .background(MaterialTheme.colors.secondary) - ) - Spacer(modifier = Modifier.width(8.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = comment?.author?.alias ?: "", - fontWeight = FontWeight.W500, - color = MaterialTheme.colors.primary.copy(0.9f) - ) - val text = comment?.message ?: "" - Text( - maxLines = 1, - text = Jsoup.parse(text).text(), - overflow = TextOverflow.Ellipsis, - style = MaterialTheme.typography.body2, - color = MaterialTheme.colors.onSurface.copy(0.6f) - ) - - } - Spacer(modifier = Modifier.width(4.dp)) - IconButton(onClick = { parentComment = null }) { - Icon( - imageVector = Icons.Outlined.Close, - contentDescription = "", - tint = MaterialTheme.colors.secondary - ) - - } - } - } - } - Divider() - EnterCommentTextField( - focusRequester = commentTextFieldFocusRequester, - onSend = { - viewModel.comment( - text = it, - postId = parentPostId, - parentCommentId = parentComment?.id - ) - commentTextFieldFocusRequester.freeFocus() - }) - } } if (showArticleHeader) { @@ -461,30 +540,18 @@ fun CommentsScreen( .padding(8.dp), verticalAlignment = Alignment.CenterVertically ) { - it.imageUrl?.let { - AsyncImage( - modifier = Modifier - .fillMaxHeight() - .aspectRatio(1f) - .clip(RoundedCornerShape(4.dp)), - model = articleSnippet?.imageUrl, contentDescription = null, - contentScale = ContentScale.Crop - ) - Spacer(modifier = Modifier.width(8.dp)) - } - Column( + Column( + modifier = Modifier.weight(1f) ) { - it.author?.run { - Text( - text = alias, - style = MaterialTheme.typography.body2, - fontWeight = FontWeight.W500, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } + Text( + text = articleSnippet.author?.alias ?: "", + style = MaterialTheme.typography.body2, + fontWeight = FontWeight.W500, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) Text( text = articleSnippet?.title!!, @@ -493,6 +560,7 @@ fun CommentsScreen( overflow = TextOverflow.Ellipsis ) } + } Divider(modifier = Modifier.align(Alignment.BottomCenter)) } diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/screens/comments/DeletedCommentItem.kt b/app/src/main/java/com/garnegsoft/hubs/ui/screens/comments/DeletedCommentItem.kt new file mode 100644 index 00000000..fb73e0f4 --- /dev/null +++ b/app/src/main/java/com/garnegsoft/hubs/ui/screens/comments/DeletedCommentItem.kt @@ -0,0 +1,35 @@ +package com.garnegsoft.hubs.ui.screens.comments + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + + +@Composable +fun DeletedCommentItem( + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(26.dp)) + .background(MaterialTheme.colors.surface) + .padding(horizontal = 16.dp, vertical = 48.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = "НЛО прилетело и опубликовало эту надпись здесь", + textAlign = TextAlign.Center, + color = MaterialTheme.colors.onSurface.copy(0.5f)) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/screens/history/ArticleHistoryCard.kt b/app/src/main/java/com/garnegsoft/hubs/ui/screens/history/ArticleHistoryCard.kt new file mode 100644 index 00000000..d343df03 --- /dev/null +++ b/app/src/main/java/com/garnegsoft/hubs/ui/screens/history/ArticleHistoryCard.kt @@ -0,0 +1,103 @@ +package com.garnegsoft.hubs.ui.screens.history + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.compose.AsyncImage +import com.garnegsoft.hubs.api.history.HistoryArticle +import com.garnegsoft.hubs.api.history.HistoryEntity +import com.garnegsoft.hubs.api.utils.formatTimestamp +import com.garnegsoft.hubs.ui.common.feedCards.article.ArticleCardStyle +import com.garnegsoft.hubs.ui.theme.HubsTheme + + +@Composable +fun ArticleHistoryCard( + entity: HistoryEntity, + articleData: HistoryArticle, + onClick: () -> Unit, + style: ArticleCardStyle +) { + HubsTheme { + Column() { +// Row( +// modifier = Modifier +// .padding(horizontal = 12.dp) +// .fillMaxWidth(), +// ) { +// Text(text = "Статья", color = MaterialTheme.colors.onBackground.copy(0.4f), +// fontWeight = FontWeight.W500,) +// Spacer(modifier = Modifier.weight(1f)) +// Text( +// text = remember { formatTimestamp(entity.timestamp) }, color = MaterialTheme.colors.onBackground.copy(0.4f), +// fontWeight = FontWeight.W500, +// textAlign = TextAlign.End) +// } +// +// Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier + .clip(style.cardShape) + .clickable(onClick = onClick) + .background(style.backgroundColor) + .padding(style.innerPadding) + ) { + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically) { + AsyncImage( + modifier = Modifier + .size(style.authorAvatarSize) + .clip(style.innerElementsShape), + model = articleData.authorAvatarUrl, + contentDescription = null) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = articleData.authorAlias, + fontWeight = FontWeight.W500, + color = MaterialTheme.colors.onSurface.copy(0.5f) + ) + } + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = articleData.title, + fontSize = 18.sp, + fontWeight = FontWeight.W500 + ) + } + articleData.thumbnailUrl?.let { + Spacer(modifier = Modifier.width(style.innerPadding.div(2))) + AsyncImage( + modifier = Modifier + .size(80.dp) + .clip(style.innerElementsShape), + model = articleData.thumbnailUrl, + contentDescription = null, + contentScale = ContentScale.Crop + ) + } + + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/screens/history/HistoryLazyColumn.kt b/app/src/main/java/com/garnegsoft/hubs/ui/screens/history/HistoryLazyColumn.kt new file mode 100644 index 00000000..f3832d6f --- /dev/null +++ b/app/src/main/java/com/garnegsoft/hubs/ui/screens/history/HistoryLazyColumn.kt @@ -0,0 +1,144 @@ +package com.garnegsoft.hubs.ui.screens.history + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.CircularProgressIndicator +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.garnegsoft.hubs.api.history.HistoryActionType +import com.garnegsoft.hubs.api.history.HistoryEntityListModel +import com.garnegsoft.hubs.api.history.getArticle +import com.garnegsoft.hubs.api.utils.formatFoundationDate +import com.garnegsoft.hubs.ui.common.BaseHubsLazyColumn +import com.garnegsoft.hubs.ui.common.feedCards.article.ArticleCardStyle +import java.util.Calendar +import java.util.Date + + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun HistoryLazyColumn( + model: HistoryEntityListModel, + onArticleClick: (Int) -> Unit +) { + LaunchedEffect(key1 = Unit, block = { + if (!model.data.isInitialized) { + model.loadFirstPage() + } + }) + + val list by model.data.observeAsState() + + list?.let { data -> + val lastLoadedPage by model.lastLoadedPage.observeAsState() + BaseHubsLazyColumn( + data = data, + onScrollEnd = { + model.loadNextPage() + }, + lazyList = { state -> + ArticleCardStyle.defaultArticleCardStyle()?.let { + LazyColumn( + state = state, + contentPadding = PaddingValues(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + var lastDay = 0 + + data.list.forEach { entity -> + val calendar = + Calendar.getInstance().apply { time = Date(entity.timestamp) } + val dayOfEvent = calendar.get(Calendar.DAY_OF_YEAR) + + if (dayOfEvent != lastDay) { + stickyHeader { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center + ) { + Box( + modifier = Modifier + .padding(vertical = 2.dp) + .shadow(0.dp, CircleShape) + .clip(CircleShape) + .background(MaterialTheme.colors.surface) + .border( + 1.dp, + MaterialTheme.colors.onBackground.copy(0.1f), + CircleShape + ) + .padding(horizontal = 12.dp, vertical = 4.dp) + ) { + Text( + text = remember { + formatFoundationDate( + calendar.get( + Calendar.DAY_OF_MONTH + ).toString(), + (calendar.get(Calendar.MONTH) + 1).toString(), + calendar.get(Calendar.YEAR).toString() + )!! + }, + fontWeight = FontWeight.W500, + color = MaterialTheme.colors.onBackground.copy(0.5f) + ) + } + } + } + } + item { + if (entity.actionType == HistoryActionType.Article) { + + ArticleHistoryCard( + entity = entity, + articleData = remember { entity.getArticle() }, + onClick = { onArticleClick(entity.getArticle().articleId) }, + style = it + ) + + } + } + lastDay = dayOfEvent + } + lastLoadedPage?.let { + if (data.pagesCount > it) { + item { + Box(modifier = Modifier.fillMaxWidth()) { + CircularProgressIndicator( + modifier = Modifier.align( + Alignment.Center + ) + ) + } + } + } + } + } + } + }, + lazyListState = rememberLazyListState() + ) + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/screens/history/HistoryScreen.kt b/app/src/main/java/com/garnegsoft/hubs/ui/screens/history/HistoryScreen.kt new file mode 100644 index 00000000..0f82ab1b --- /dev/null +++ b/app/src/main/java/com/garnegsoft/hubs/ui/screens/history/HistoryScreen.kt @@ -0,0 +1,92 @@ +package com.garnegsoft.hubs.ui.screens.history + +import android.content.Context +import android.util.Log +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Scaffold +import androidx.compose.material.Text +import androidx.compose.material.TopAppBar +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel +import com.garnegsoft.hubs.api.history.HistoryActionType +import com.garnegsoft.hubs.api.history.HistoryDatabase +import com.garnegsoft.hubs.api.history.HistoryEntity +import com.garnegsoft.hubs.api.history.HistoryEntityListModel +import com.garnegsoft.hubs.api.history.getArticle +import com.garnegsoft.hubs.api.utils.formatFoundationDate +import com.garnegsoft.hubs.ui.common.feedCards.article.ArticleCardStyle +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch +import java.util.Calendar +import java.util.Date + + +class HistoryScreenViewModel(context: Context) : ViewModel() { + val model = HistoryEntityListModel( + coroutineScope = viewModelScope, + dao = HistoryDatabase.getDb(context).dao() + ) +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun HistoryScreen( + onBack: () -> Unit, + onArticleClick: (articleId: Int) -> Unit, + onUserClick: (alias: String) -> Unit, + onHubClick: (alias: String) -> Unit, + onCompanyClick: (alias: String) -> Unit, +) { + val context = LocalContext.current + val viewModel = viewModel { HistoryScreenViewModel(context)} + + Scaffold( + topBar = { + TopAppBar( + elevation = 0.dp, + title = { Text(text = "История") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(imageVector = Icons.Default.ArrowBack, contentDescription = null) + } + } + ) + } + ) { + Box(modifier = Modifier.padding(it)) { + HistoryLazyColumn(model = viewModel.model, onArticleClick = onArticleClick) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/screens/hub/HubArticlesFilter.kt b/app/src/main/java/com/garnegsoft/hubs/ui/screens/hub/HubArticlesFilter.kt index eb2e61ab..6c87dd38 100644 --- a/app/src/main/java/com/garnegsoft/hubs/ui/screens/hub/HubArticlesFilter.kt +++ b/app/src/main/java/com/garnegsoft/hubs/ui/screens/hub/HubArticlesFilter.kt @@ -16,17 +16,16 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import com.garnegsoft.hubs.api.Filter import com.garnegsoft.hubs.api.FilterPeriod -import com.garnegsoft.hubs.api.PostComplexity +import com.garnegsoft.hubs.api.PublicationComplexity import com.garnegsoft.hubs.ui.common.BaseFilterDialog import com.garnegsoft.hubs.ui.common.HubsFilterChip import com.garnegsoft.hubs.ui.common.TitledColumn -import com.garnegsoft.hubs.ui.screens.main.ArticlesFilterState data class HubArticlesFilter( val showLast: Boolean, val minRating: Int = -1, val period: FilterPeriod = FilterPeriod.Day, - val complexity: PostComplexity + val complexity: PublicationComplexity ) : Filter { override fun toArgsMap(): Map { return if (showLast){ @@ -45,12 +44,12 @@ data class HubArticlesFilter( FilterPeriod.AllTime -> "alltime" } ) - } + if (complexity != PostComplexity.None) { + } + if (complexity != PublicationComplexity.None) { mapOf( "complexity" to when (complexity) { - PostComplexity.Low -> "easy" - PostComplexity.Medium -> "medium" - PostComplexity.High -> "hard" + PublicationComplexity.Low -> "easy" + PublicationComplexity.Medium -> "medium" + PublicationComplexity.High -> "hard" else -> throw IllegalArgumentException("mapping of this complexity is not supported") } ) @@ -73,9 +72,9 @@ data class HubArticlesFilter( FilterPeriod.AllTime -> "все время" } } + when (complexity) { - PostComplexity.High -> ", сложные" - PostComplexity.Medium -> ", средние" - PostComplexity.Low -> ", простые" + PublicationComplexity.High -> ", сложные" + PublicationComplexity.Medium -> ", средние" + PublicationComplexity.Low -> ", простые" else -> "" } @@ -214,23 +213,23 @@ fun HubArticlesFilter( horizontalArrangement = Arrangement.spacedBy(8.dp) ) { HubsFilterChip( - selected = complexity == PostComplexity.None, - onClick = { complexity = PostComplexity.None }) { + selected = complexity == PublicationComplexity.None, + onClick = { complexity = PublicationComplexity.None }) { Text(text = "Все") } HubsFilterChip( - selected = complexity == PostComplexity.Low, - onClick = { complexity = PostComplexity.Low }) { + selected = complexity == PublicationComplexity.Low, + onClick = { complexity = PublicationComplexity.Low }) { Text(text = "Простой") } HubsFilterChip( - selected = complexity == PostComplexity.Medium, - onClick = { complexity = PostComplexity.Medium }) { + selected = complexity == PublicationComplexity.Medium, + onClick = { complexity = PublicationComplexity.Medium }) { Text(text = "Средний") } HubsFilterChip( - selected = complexity == PostComplexity.High, - onClick = { complexity = PostComplexity.High }) { + selected = complexity == PublicationComplexity.High, + onClick = { complexity = PublicationComplexity.High }) { Text(text = "Сложный") } diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/screens/hub/HubProfile.kt b/app/src/main/java/com/garnegsoft/hubs/ui/screens/hub/HubProfile.kt index 9b3abcb9..b6d8fa50 100644 --- a/app/src/main/java/com/garnegsoft/hubs/ui/screens/hub/HubProfile.kt +++ b/app/src/main/java/com/garnegsoft/hubs/ui/screens/hub/HubProfile.kt @@ -4,12 +4,10 @@ import androidx.compose.foundation.ScrollState import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.scrollable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.ContentAlpha -import androidx.compose.material.Divider import androidx.compose.material.MaterialTheme import androidx.compose.material.Text import androidx.compose.runtime.Composable @@ -21,7 +19,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight @@ -38,8 +35,6 @@ import com.garnegsoft.hubs.ui.common.BasicTitledColumn import com.garnegsoft.hubs.ui.common.RefreshableContainer import com.garnegsoft.hubs.ui.common.TitledColumn import com.garnegsoft.hubs.ui.theme.DefaultRatingIndicatorColor -import com.garnegsoft.hubs.ui.theme.RatingNegative -import com.garnegsoft.hubs.ui.theme.RatingPositive import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/screens/hub/HubScreen.kt b/app/src/main/java/com/garnegsoft/hubs/ui/screens/hub/HubScreen.kt index 203dbf55..4e40dff7 100644 --- a/app/src/main/java/com/garnegsoft/hubs/ui/screens/hub/HubScreen.kt +++ b/app/src/main/java/com/garnegsoft/hubs/ui/screens/hub/HubScreen.kt @@ -1,14 +1,9 @@ package com.garnegsoft.hubs.ui.screens.hub -import ArticlesListController import android.content.Intent import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack @@ -17,44 +12,23 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.livedata.observeAsState import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelStoreOwner -import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel -import coil.compose.AsyncImage -import com.garnegsoft.hubs.api.HabrList -import com.garnegsoft.hubs.api.article.list.ArticleSnippet -import com.garnegsoft.hubs.api.company.list.CompaniesListController -import com.garnegsoft.hubs.api.company.list.CompanySnippet -import com.garnegsoft.hubs.api.hub.Hub import com.garnegsoft.hubs.api.hub.HubController -import com.garnegsoft.hubs.api.user.list.UserSnippet -import com.garnegsoft.hubs.api.user.list.UsersListController import com.garnegsoft.hubs.ui.common.* import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.rememberScrollState -import androidx.compose.runtime.rememberCoroutineScope -import com.garnegsoft.hubs.api.utils.formatLongNumbers import com.garnegsoft.hubs.ui.common.snippetsPages.ArticlesListPageWithFilter import com.garnegsoft.hubs.ui.common.snippetsPages.CompaniesListPage import com.garnegsoft.hubs.ui.common.snippetsPages.UsersListPage -import com.garnegsoft.hubs.ui.screens.search.ArticlesSearchFilter import com.garnegsoft.hubs.ui.theme.HubInvestmentIndicatorColor -import com.garnegsoft.hubs.ui.theme.RatingPositive import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import kotlin.math.roundToInt @OptIn(ExperimentalFoundationApi::class) diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/screens/hub/HubScreenViewModel.kt b/app/src/main/java/com/garnegsoft/hubs/ui/screens/hub/HubScreenViewModel.kt index b6d8082d..c18638ec 100644 --- a/app/src/main/java/com/garnegsoft/hubs/ui/screens/hub/HubScreenViewModel.kt +++ b/app/src/main/java/com/garnegsoft/hubs/ui/screens/hub/HubScreenViewModel.kt @@ -4,17 +4,12 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.garnegsoft.hubs.api.FilterPeriod -import com.garnegsoft.hubs.api.HabrList -import com.garnegsoft.hubs.api.PostComplexity +import com.garnegsoft.hubs.api.PublicationComplexity import com.garnegsoft.hubs.api.article.ArticlesListModel -import com.garnegsoft.hubs.api.article.list.ArticleSnippet import com.garnegsoft.hubs.api.company.CompaniesListModel -import com.garnegsoft.hubs.api.company.list.CompanySnippet import com.garnegsoft.hubs.api.hub.Hub import com.garnegsoft.hubs.api.hub.HubController -import com.garnegsoft.hubs.api.hub.HubsListModel import com.garnegsoft.hubs.api.user.UsersListModel -import com.garnegsoft.hubs.api.user.list.UserSnippet import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -43,7 +38,7 @@ class HubScreenViewModel(val alias: String) : ViewModel() { showLast = true, minRating = -1, period = FilterPeriod.Day, - complexity = PostComplexity.None + complexity = PublicationComplexity.None ) ) diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/screens/main/ArticlesFilterDialog.kt b/app/src/main/java/com/garnegsoft/hubs/ui/screens/main/ArticlesFilter.kt similarity index 85% rename from app/src/main/java/com/garnegsoft/hubs/ui/screens/main/ArticlesFilterDialog.kt rename to app/src/main/java/com/garnegsoft/hubs/ui/screens/main/ArticlesFilter.kt index d4557621..bbb296cd 100644 --- a/app/src/main/java/com/garnegsoft/hubs/ui/screens/main/ArticlesFilterDialog.kt +++ b/app/src/main/java/com/garnegsoft/hubs/ui/screens/main/ArticlesFilter.kt @@ -8,11 +8,10 @@ import androidx.compose.material.* import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.garnegsoft.hubs.api.Filter import com.garnegsoft.hubs.api.FilterPeriod -import com.garnegsoft.hubs.api.PostComplexity +import com.garnegsoft.hubs.api.PublicationComplexity import com.garnegsoft.hubs.ui.common.BaseFilterDialog import com.garnegsoft.hubs.ui.common.HubsFilterChip import com.garnegsoft.hubs.ui.common.TitledColumn @@ -150,23 +149,23 @@ fun ArticlesFilterDialog( horizontalArrangement = Arrangement.spacedBy(8.dp) ) { HubsFilterChip( - selected = complexity == PostComplexity.None, - onClick = { complexity = PostComplexity.None }) { + selected = complexity == PublicationComplexity.None, + onClick = { complexity = PublicationComplexity.None }) { Text(text = "Все") } HubsFilterChip( - selected = complexity == PostComplexity.Low, - onClick = { complexity = PostComplexity.Low }) { + selected = complexity == PublicationComplexity.Low, + onClick = { complexity = PublicationComplexity.Low }) { Text(text = "Простой") } HubsFilterChip( - selected = complexity == PostComplexity.Medium, - onClick = { complexity = PostComplexity.Medium }) { + selected = complexity == PublicationComplexity.Medium, + onClick = { complexity = PublicationComplexity.Medium }) { Text(text = "Средний") } HubsFilterChip( - selected = complexity == PostComplexity.High, - onClick = { complexity = PostComplexity.High }) { + selected = complexity == PublicationComplexity.High, + onClick = { complexity = PublicationComplexity.High }) { Text(text = "Сложный") } @@ -177,23 +176,19 @@ fun ArticlesFilterDialog( } } - - } data class ArticlesFilterState( val showLast: Boolean, val minRating: Int = -1, val period: FilterPeriod = FilterPeriod.Day, - val complexity: PostComplexity + val complexity: PublicationComplexity ) : Filter { override fun toArgsMap(): Map { var argsMap: Map = if (showLast) { if (minRating == -1) { - mapOf( - "sort" to "rating", - ) + mapOf("sort" to "rating",) } else { mapOf( "sort" to "rating", @@ -213,12 +208,12 @@ data class ArticlesFilterState( ) } - if (complexity != PostComplexity.None) { + if (complexity != PublicationComplexity.None) { argsMap += mapOf( "complexity" to when (complexity) { - PostComplexity.Low -> "easy" - PostComplexity.Medium -> "medium" - PostComplexity.High -> "hard" + PublicationComplexity.Low -> "easy" + PublicationComplexity.Medium -> "medium" + PublicationComplexity.High -> "hard" else -> throw IllegalArgumentException("mapping of this complexity is not supported") } ) @@ -242,10 +237,9 @@ data class ArticlesFilterState( FilterPeriod.AllTime -> "все время" } } + when (complexity) { - PostComplexity.High -> ", сложные" - PostComplexity.Medium -> ", средние" - PostComplexity.Low -> ", простые" - + PublicationComplexity.High -> ", сложные" + PublicationComplexity.Medium -> ", средние" + PublicationComplexity.Low -> ", простые" else -> "" } diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/screens/main/ArticlesScreenViewModel.kt b/app/src/main/java/com/garnegsoft/hubs/ui/screens/main/ArticlesScreenViewModel.kt index 652d35ec..21aa859d 100644 --- a/app/src/main/java/com/garnegsoft/hubs/ui/screens/main/ArticlesScreenViewModel.kt +++ b/app/src/main/java/com/garnegsoft/hubs/ui/screens/main/ArticlesScreenViewModel.kt @@ -1,35 +1,27 @@ package com.garnegsoft.hubs.ui.screens.main -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.garnegsoft.hubs.api.FilterPeriod -import com.garnegsoft.hubs.api.HabrList -import com.garnegsoft.hubs.api.PostComplexity +import com.garnegsoft.hubs.api.PublicationComplexity import com.garnegsoft.hubs.api.article.ArticlesListModel -import com.garnegsoft.hubs.api.article.list.ArticleSnippet import com.garnegsoft.hubs.api.company.CompaniesListModel -import com.garnegsoft.hubs.api.company.list.CompanySnippet import com.garnegsoft.hubs.api.hub.HubsListModel -import com.garnegsoft.hubs.api.hub.list.HubSnippet import com.garnegsoft.hubs.api.user.UsersListModel -import com.garnegsoft.hubs.api.user.list.UserSnippet -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch class ArticlesScreenViewModel : ViewModel() { val myFeedArticlesListModel = ArticlesListModel( path = "articles", coroutineScope = viewModelScope, - baseArgs = arrayOf("myFeed" to "true", "complexity" to "all", "score" to "all"), - initialFilter = MyFeedFilter(showNews = false, showArticles = true) + baseArgs = arrayOf("myFeed" to "true"), + initialFilter = MyFeedFilter(showNews = false, showArticles = true, minRating = -1, complexity = PublicationComplexity.None) ) val articlesListModel = ArticlesListModel( path = "articles", coroutineScope = viewModelScope, - initialFilter = ArticlesFilterState(showLast = true, complexity = PostComplexity.None), + initialFilter = ArticlesFilterState(showLast = true, complexity = PublicationComplexity.None) ) val newsListModel = ArticlesListModel( diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/screens/main/ContinueReadSnackBar.kt b/app/src/main/java/com/garnegsoft/hubs/ui/screens/main/ContinueReadSnackBar.kt index 6837bdfc..1cdc54e7 100644 --- a/app/src/main/java/com/garnegsoft/hubs/ui/screens/main/ContinueReadSnackBar.kt +++ b/app/src/main/java/com/garnegsoft/hubs/ui/screens/main/ContinueReadSnackBar.kt @@ -28,8 +28,8 @@ fun ContinueReadSnackBar( Row(modifier = Modifier .fillMaxWidth() .padding(16.dp) - .shadow(8.dp, shape = RoundedCornerShape(10.dp)) - .clip(RoundedCornerShape(10.dp)) + .shadow(8.dp, shape = RoundedCornerShape(18.dp)) + .clip(RoundedCornerShape(18.dp)) .background(if (MaterialTheme.colors.isLight) MaterialTheme.colors.surface else Color( 0xFF414141 ) diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/screens/main/ArticlesScreen.kt b/app/src/main/java/com/garnegsoft/hubs/ui/screens/main/MainScreen.kt similarity index 91% rename from app/src/main/java/com/garnegsoft/hubs/ui/screens/main/ArticlesScreen.kt rename to app/src/main/java/com/garnegsoft/hubs/ui/screens/main/MainScreen.kt index 9e1b56fa..28097ffb 100755 --- a/app/src/main/java/com/garnegsoft/hubs/ui/screens/main/ArticlesScreen.kt +++ b/app/src/main/java/com/garnegsoft/hubs/ui/screens/main/MainScreen.kt @@ -17,17 +17,13 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.* -import androidx.datastore.preferences.core.edit import androidx.lifecycle.ViewModelStoreOwner import androidx.lifecycle.viewmodel.compose.viewModel import com.garnegsoft.hubs.R -import com.garnegsoft.hubs.api.dataStore.HubsDataStore -import com.garnegsoft.hubs.api.dataStore.authDataStoreFlowWithDefault -import com.garnegsoft.hubs.api.dataStore.lastReadDataStoreFlow +import com.garnegsoft.hubs.api.dataStore.AuthDataController +import com.garnegsoft.hubs.api.dataStore.LastReadArticleController import com.garnegsoft.hubs.api.rememberCollapsingContentState -import com.garnegsoft.hubs.lastReadDataStore import com.garnegsoft.hubs.ui.common.* -import com.garnegsoft.hubs.ui.common.snippetsPages.ArticlesListPage import com.garnegsoft.hubs.ui.common.snippetsPages.ArticlesListPageWithFilter import com.garnegsoft.hubs.ui.common.snippetsPages.CompaniesListPage import com.garnegsoft.hubs.ui.common.snippetsPages.HubsListPage @@ -37,7 +33,7 @@ import kotlinx.coroutines.* @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterialApi::class) @Composable -fun ArticlesScreen( +fun MainScreen( viewModelStoreOwner: ViewModelStoreOwner, onArticleClicked: (articleId: Int) -> Unit, onSearchClicked: () -> Unit, @@ -49,11 +45,9 @@ fun ArticlesScreen( ) { val context = LocalContext.current var isAuthorized by rememberSaveable() { mutableStateOf(false) } - val authorizedState by context.authDataStoreFlowWithDefault( - HubsDataStore.Auth.Keys.Authorized, - false - ) + val authorizedState by AuthDataController.isAuthorizedFlow(context) .collectAsState(initial = null) + LaunchedEffect(key1 = authorizedState, block = { isAuthorized = authorizedState == true }) @@ -62,8 +56,8 @@ fun ArticlesScreen( val scaffoldState = rememberScaffoldState() - val lastArticleRead by context.lastReadDataStoreFlow(HubsDataStore.LastRead.Keys.LastArticleRead) - .collectAsState(initial = -1) + val lastArticleRead by LastReadArticleController.getLastArticleFlow(context) + .collectAsState(initial = null) var showSnackBar by rememberSaveable { mutableStateOf(true) @@ -85,9 +79,7 @@ fun ArticlesScreen( if (snackbarResult == SnackbarResult.ActionPerformed) { launch(Dispatchers.Main) { onArticleClicked(it.id) } } else { - context.lastReadDataStore.edit { - it[HubsDataStore.LastRead.Keys.LastArticleRead] = 0 - } + LastReadArticleController.clearLastArticle(context) } showSnackBar = false @@ -148,6 +140,8 @@ fun ArticlesScreen( val authorsLazyListState = rememberLazyListState() val companiesLazyListState = rememberLazyListState() + + val pages = remember(key1 = isAuthorized) { var map = mapOf Unit>( "Статьи" to { diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/screens/main/Menus.kt b/app/src/main/java/com/garnegsoft/hubs/ui/screens/main/Menus.kt index 9f1f65f0..0dcf5b04 100644 --- a/app/src/main/java/com/garnegsoft/hubs/ui/screens/main/Menus.kt +++ b/app/src/main/java/com/garnegsoft/hubs/ui/screens/main/Menus.kt @@ -1,26 +1,30 @@ package com.garnegsoft.hubs.ui.screens.main -import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.tween +import androidx.compose.animation.core.updateTransition import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.ExitToApp import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.MoreVert import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material.icons.outlined.Star import androidx.compose.runtime.* -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.IntOffset @@ -34,7 +38,6 @@ import androidx.compose.ui.window.PopupProperties import coil.compose.AsyncImage import com.garnegsoft.hubs.R import com.garnegsoft.hubs.api.utils.placeholderColorLegacy -import kotlinx.coroutines.delay @Composable fun AuthorizedMenu( @@ -45,6 +48,7 @@ fun AuthorizedMenu( onCommentsClick: () -> Unit, onBookmarksClick: () -> Unit, onSavedArticlesClick: () -> Unit, + onHistoryClick: () -> Unit, onSettingsClick: () -> Unit, onAboutClick: () -> Unit, ) { @@ -55,7 +59,7 @@ fun AuthorizedMenu( modifier = Modifier .size(32.dp) .clip(RoundedCornerShape(8.dp)) - .background(Color.White), + .background(if (MaterialTheme.colors.isLight) Color.Transparent else Color.White), contentScale = ContentScale.FillBounds, model = avatarUrl, contentDescription = "" ) @@ -78,27 +82,35 @@ fun AuthorizedMenu( } } - var visible by rememberSaveable { - mutableStateOf(false) - } - LaunchedEffect(key1 = expanded, block = { - if (expanded) { - visible = expanded + val menuTransition = updateTransition(targetState = expanded) + + val alpha by menuTransition.animateFloat( + transitionSpec = { + if (this.targetState) + tween(150) + else + tween(150) + } - }) - LaunchedEffect(key1 = visible, block = { - delay(150) - if (!visible) { - expanded = false + ) { + if (it) 1f else 0.0f + } + + val itemsAnimation by menuTransition.animateFloat( + transitionSpec = { + if (this.targetState) + tween(150, 50) + else + tween(100) + } - - }) - val alpha by animateFloatAsState( - targetValue = if (visible) 1f else 0.0f, - animationSpec = tween(150) - ) + ) { + if (it) 1f else 0.0f + } + + val itemsOffset = 20 - if (expanded) { + if (menuTransition.targetState || expanded || menuTransition.currentState) { Popup( popupPositionProvider = object : PopupPositionProvider { override fun calculatePosition( @@ -113,8 +125,9 @@ fun AuthorizedMenu( }, properties = PopupProperties(true), - onDismissRequest = { visible = false } + onDismissRequest = { expanded = false } ) { + Box( modifier = Modifier .alpha(alpha) @@ -131,111 +144,207 @@ fun AuthorizedMenu( Modifier .width(intrinsicSize = IntrinsicSize.Max) .widthIn(min = 150.dp) + .verticalScroll(rememberScrollState()) ) { - MenuItem(title = userAlias, icon = { - if (avatarUrl != null) { - AsyncImage( - modifier = Modifier - .size(32.dp) - .clip(RoundedCornerShape(8.dp)) - .background(Color.White), - contentScale = ContentScale.FillBounds, - model = avatarUrl, contentDescription = "" + MenuItem( + modifier = Modifier.graphicsLayer { + this.translationY = -itemsOffset + itemsOffset * itemsAnimation + this.alpha = itemsAnimation + 0.6f + }, + title = userAlias, icon = { + if (avatarUrl != null) { + AsyncImage( + modifier = Modifier + .size(32.dp) + .clip(RoundedCornerShape(8.dp)) + .background(Color.White), + contentScale = ContentScale.FillBounds, + model = avatarUrl, contentDescription = "" + ) + } else { + Icon( + modifier = Modifier + .size(32.dp) + .border( + width = 2.dp, + color = placeholderColorLegacy(userAlias), + shape = RoundedCornerShape(8.dp) + ) + .background( + Color.White, + shape = RoundedCornerShape(8.dp) + ) + .padding(2.5.dp), + painter = painterResource(id = R.drawable.user_avatar_placeholder), + contentDescription = "", + tint = placeholderColorLegacy(userAlias) + ) + } + }, onClick = { + onProfileClick() + expanded = false + } + ) + Divider( + modifier = Modifier + .padding( + horizontal = 12.dp, + vertical = 4.dp ) - } else { + .graphicsLayer { + this.translationY = + -itemsOffset + itemsOffset * itemsAnimation + this.alpha = itemsAnimation + 0.6f + } + ) + + MenuItem( + modifier = Modifier.graphicsLayer { + this.translationY = -itemsOffset + itemsOffset * itemsAnimation + this.alpha = itemsAnimation + 0.55f + }, + title = "Статьи", icon = { Icon( - modifier = Modifier - .size(32.dp) - .border( - width = 2.dp, color = placeholderColorLegacy(userAlias), - shape = RoundedCornerShape(8.dp) - ) - .background( - Color.White, - shape = RoundedCornerShape(8.dp) - ) - .padding(2.5.dp), - painter = painterResource(id = R.drawable.user_avatar_placeholder), + painter = painterResource(id = R.drawable.article), contentDescription = "", - tint = placeholderColorLegacy(userAlias) + tint = MaterialTheme.colors.onBackground ) + }, onClick = { + onArticlesClick() + expanded = false } - }, onClick = onProfileClick) - Divider( - modifier = Modifier.padding( - horizontal = 12.dp, - vertical = 4.dp - ) ) - MenuItem(title = "Статьи", icon = { - Icon( - painter = painterResource(id = R.drawable.article), - contentDescription = "", - tint = MaterialTheme.colors.onBackground - ) - }, onClick = onArticlesClick) + MenuItem( + modifier = Modifier.graphicsLayer { + this.translationY = -itemsOffset + itemsOffset * itemsAnimation + this.alpha = itemsAnimation + 0.5f + }, + title = "Комментарии", icon = { + Icon( + painter = painterResource(id = R.drawable.comments_icon), + contentDescription = "", + tint = MaterialTheme.colors.onBackground + ) + }, onClick = { + onCommentsClick() + expanded = false + } + ) - MenuItem(title = "Комментарии", icon = { - Icon( - painter = painterResource(id = R.drawable.comments_icon), - contentDescription = "", - tint = MaterialTheme.colors.onBackground - ) - }, onClick = onCommentsClick) + MenuItem( + modifier = Modifier.graphicsLayer { + this.translationY = -itemsOffset + itemsOffset * itemsAnimation + this.alpha = itemsAnimation + 0.4f + }, + title = "Закладки", icon = { + Icon( + painter = painterResource(id = R.drawable.bookmark), + contentDescription = "", + tint = MaterialTheme.colors.onBackground + ) + }, onClick = { + onBookmarksClick() + expanded = false + } + ) - MenuItem(title = "Закладки", icon = { - Icon( - painter = painterResource(id = R.drawable.bookmark), - contentDescription = "", - tint = MaterialTheme.colors.onBackground - ) - }, onClick = onBookmarksClick) + MenuItem( + modifier = Modifier.graphicsLayer { + this.translationY = -itemsOffset + itemsOffset * itemsAnimation + this.alpha = itemsAnimation + 0.3f + }, + title = "Скачанные", icon = { + Icon( + painter = painterResource(id = R.drawable.download), + contentDescription = "", + tint = MaterialTheme.colors.onBackground + ) + }, onClick = { + onSavedArticlesClick() + expanded = false + } + ) - MenuItem(title = "Скачанные", icon = { - Icon( - painter = painterResource(id = R.drawable.download), - contentDescription = "", - tint = MaterialTheme.colors.onBackground - ) - }, onClick = onSavedArticlesClick) + MenuItem( + modifier = Modifier.graphicsLayer { + this.translationY = -itemsOffset + itemsOffset * itemsAnimation + this.alpha = itemsAnimation + 0.2f + }, + title = "История", + icon = { + Icon( + painter = painterResource(id = R.drawable.history), + contentDescription = null, + tint = MaterialTheme.colors.onBackground + ) + }, + onClick = { + onHistoryClick() + expanded = false + } + ) - MenuItem(title = "Настройки", icon = { - Icon( - imageVector = Icons.Outlined.Settings, - contentDescription = "", - tint = MaterialTheme.colors.onBackground - ) - }, onClick = onSettingsClick) + MenuItem( + modifier = Modifier.graphicsLayer { + this.translationY = -itemsOffset + itemsOffset * itemsAnimation + this.alpha = itemsAnimation + 0.1f + }, + title = "Настройки", icon = { + Icon( + imageVector = Icons.Outlined.Settings, + contentDescription = "", + tint = MaterialTheme.colors.onBackground + ) + }, onClick = { + onSettingsClick() + expanded = false + } + ) Divider( - modifier = Modifier.padding( - horizontal = 12.dp, - vertical = 4.dp - ) + modifier = Modifier + .padding( + horizontal = 12.dp, + vertical = 4.dp + ) + .graphicsLayer { + this.translationY = + -itemsOffset + itemsOffset * itemsAnimation + this.alpha = itemsAnimation + 0.05f + } ) - MenuItem(title = "О приложении", icon = { - Icon( - imageVector = Icons.Outlined.Info, - contentDescription = "", - tint = MaterialTheme.colors.onBackground - ) - }, onClick = onAboutClick) + MenuItem( + modifier = Modifier.graphicsLayer { + this.translationY = -itemsOffset + itemsOffset * itemsAnimation + this.alpha = itemsAnimation + 0.0f + }, + title = "О приложении", icon = { + Icon( + imageVector = Icons.Outlined.Info, + contentDescription = "", + tint = MaterialTheme.colors.onBackground + ) + }, onClick = { + onAboutClick() + expanded = false + } + ) } } } - } } - } + @Composable fun UnauthorizedMenu( onLoginClick: () -> Unit, onAboutClick: () -> Unit, onSettingsClick: () -> Unit, + onHistoryClick: () -> Unit, onSavedArticlesClick: () -> Unit ) { var expanded by remember { mutableStateOf(false) } @@ -254,7 +363,10 @@ fun UnauthorizedMenu( contentDescription = "", tint = MaterialTheme.colors.onBackground ) - }, onClick = onLoginClick) + }, onClick = { + onLoginClick() + expanded = false + }) MenuItem(title = "Скачанные", icon = { Icon( @@ -262,7 +374,25 @@ fun UnauthorizedMenu( contentDescription = "", tint = MaterialTheme.colors.onBackground ) - }, onClick = onSavedArticlesClick) + }, onClick = { + onSavedArticlesClick() + expanded = false + }) + + MenuItem( + title = "История", + icon = { + Icon( + painter = painterResource(id = R.drawable.history), + contentDescription = null, + tint = MaterialTheme.colors.onBackground + ) + }, + onClick = { + onHistoryClick() + expanded = false + } + ) MenuItem(title = "Настройки", icon = { Icon( @@ -270,7 +400,10 @@ fun UnauthorizedMenu( contentDescription = "", tint = MaterialTheme.colors.onBackground ) - }, onClick = onSettingsClick) + }, onClick = { + onSettingsClick() + expanded = false + }) Divider(modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp)) @@ -281,18 +414,22 @@ fun UnauthorizedMenu( modifier = Modifier.size(24.dp), tint = MaterialTheme.colors.onBackground ) - }, onClick = onAboutClick) + }, onClick = { + onAboutClick() + expanded = false + }) } } @Composable fun MenuItem( title: String, + modifier: Modifier = Modifier, icon: @Composable () -> Unit, onClick: () -> Unit ) { Row( - modifier = Modifier + modifier = modifier .clickable(onClick = onClick) .padding(14.dp), verticalAlignment = Alignment.CenterVertically diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/screens/main/MyFeedFilter.kt b/app/src/main/java/com/garnegsoft/hubs/ui/screens/main/MyFeedFilter.kt index 13a12f85..ecc96181 100644 --- a/app/src/main/java/com/garnegsoft/hubs/ui/screens/main/MyFeedFilter.kt +++ b/app/src/main/java/com/garnegsoft/hubs/ui/screens/main/MyFeedFilter.kt @@ -1,18 +1,22 @@ package com.garnegsoft.hubs.ui.screens.main import android.widget.Toast -import androidx.appcompat.view.menu.ShowableListMenu +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.rememberScrollState import androidx.compose.material.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.platform.LocalContext import androidx.compose.ui.unit.dp import com.garnegsoft.hubs.api.Filter +import com.garnegsoft.hubs.api.PublicationComplexity import com.garnegsoft.hubs.ui.common.BaseFilterDialog import com.garnegsoft.hubs.ui.common.HubsFilterChip import com.garnegsoft.hubs.ui.common.TitledColumn @@ -20,7 +24,9 @@ import com.garnegsoft.hubs.ui.common.TitledColumn data class MyFeedFilter( val showArticles: Boolean, - val showNews: Boolean + val showNews: Boolean, + val minRating: Int, + val complexity: PublicationComplexity ) : Filter { override fun toArgsMap(): Map { return mutableMapOf().apply { @@ -31,12 +37,42 @@ data class MyFeedFilter( } if (showNews) this.put("types[$argsCount]", "news") + + put( + "complexity", when (complexity) { + PublicationComplexity.None -> "all" + PublicationComplexity.Low -> "easy" + PublicationComplexity.Medium -> "medium" + PublicationComplexity.High -> "hard" + else -> throw IllegalArgumentException("mapping of this complexity is not supported") + } + ) + + if (minRating == -1) { + put("score", "all") + } else { + put("score", minRating.toString()) + } + } } override fun getTitle(): String { - return if (showNews && showArticles) "Статьи & Новости" - else if (showArticles) "Статьи" else "Новости" + return buildString { + if (showNews && showArticles) append("Статьи & Новости") + else if (showArticles) append("Статьи") else append("Новости") + + if (minRating > -1) + append(" с рейтингом ≥${minRating}") + + when (complexity) { + PublicationComplexity.High -> append(", сложные") + PublicationComplexity.Medium -> append(", средние") + PublicationComplexity.Low -> append(", простые") + else -> {} + } + } + } } @@ -47,7 +83,6 @@ fun MyFeedFilter( onDismiss: () -> Unit, onDone: (MyFeedFilter) -> Unit ) { - val context = LocalContext.current var showArticles by rememberSaveable { mutableStateOf(defaultValues.showArticles) } @@ -55,23 +90,132 @@ fun MyFeedFilter( mutableStateOf(defaultValues.showNews) } + var minRating by rememberSaveable { + mutableStateOf(defaultValues.minRating) + } + + var complexity by rememberSaveable { + mutableStateOf(defaultValues.complexity) + } + + val context = LocalContext.current + BaseFilterDialog( onDismiss = onDismiss, onDone = { - if (!showNews && !showArticles){ - Toast.makeText(context, "Выберите хотя бы 1 тип публикаций", Toast.LENGTH_SHORT).show() + if (!showArticles && !showNews){ + Toast.makeText(context, "Выберите 1 тип публикаций", Toast.LENGTH_SHORT).show() } else { - onDone(MyFeedFilter(showNews = showNews, showArticles = showArticles)) + onDone( + MyFeedFilter( + showNews = showNews, + showArticles = showArticles, + minRating = minRating, + complexity = complexity + ) + ) } } ) { - TitledColumn(title = "Тип публикации") { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - HubsFilterChip(selected = showArticles, onClick = { showArticles = !showArticles }) { - Text(text = "Статьи") + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + + + TitledColumn(title = "Тип публикации") { + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + HubsFilterChip( + selected = showArticles, + onClick = { showArticles = !showArticles }) { + Text(text = "Статьи") + } + HubsFilterChip( + selected = showNews, + onClick = { showNews = !showNews }) { + Text(text = "Новости") + } } - HubsFilterChip(selected = showNews, onClick = { showNews = !showNews }) { - Text(text = "Новости") + } + + TitledColumn(title = "Порог рейтинга") { + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + HubsFilterChip( + selected = minRating == -1, + onClick = { minRating = -1 } + ) { + Text(text = "Все") + } + HubsFilterChip( + selected = minRating == 0, + onClick = { minRating = 0 } + ) { + Text(text = "≥0") + } + HubsFilterChip( + selected = minRating == 10, + onClick = { minRating = 10 } + ) { + Text(text = "≥10") + } + HubsFilterChip( + selected = minRating == 25, + onClick = { minRating = 25 } + ) { + Text(text = "≥25") + } + HubsFilterChip( + selected = minRating == 50, + onClick = { minRating = 50 } + ) { + Text(text = "≥50") + } + HubsFilterChip( + selected = minRating == 100, + onClick = { minRating = 100 } + ) { + Text(text = "≥100") + } + } + } + + TitledColumn(title = "Уровень сложности") { + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + HubsFilterChip( + selected = complexity == PublicationComplexity.None, + onClick = { complexity = PublicationComplexity.None } + ) { + Text(text = "Все") + } + + HubsFilterChip( + selected = complexity == PublicationComplexity.Low, + onClick = { complexity = PublicationComplexity.Low } + ) { + Text(text = "Простой") + } + + HubsFilterChip( + selected = complexity == PublicationComplexity.Medium, + onClick = { complexity = PublicationComplexity.Medium } + ) { + Text(text = "Средний") + } + + HubsFilterChip( + selected = complexity == PublicationComplexity.High, + onClick = { complexity = PublicationComplexity.High } + ) { + Text(text = "Сложный") + } + + } } } diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/screens/main/NewsFilterDialog.kt b/app/src/main/java/com/garnegsoft/hubs/ui/screens/main/NewsFilter.kt similarity index 99% rename from app/src/main/java/com/garnegsoft/hubs/ui/screens/main/NewsFilterDialog.kt rename to app/src/main/java/com/garnegsoft/hubs/ui/screens/main/NewsFilter.kt index e5bfd611..ebee73c6 100644 --- a/app/src/main/java/com/garnegsoft/hubs/ui/screens/main/NewsFilterDialog.kt +++ b/app/src/main/java/com/garnegsoft/hubs/ui/screens/main/NewsFilter.kt @@ -19,7 +19,6 @@ import com.garnegsoft.hubs.api.FilterPeriod import com.garnegsoft.hubs.ui.common.BaseFilterDialog import com.garnegsoft.hubs.ui.common.HubsFilterChip import com.garnegsoft.hubs.ui.common.TitledColumn -import java.lang.StringBuilder @Composable fun NewsFilterDialog( diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/screens/main/pagesRegistry.kt b/app/src/main/java/com/garnegsoft/hubs/ui/screens/main/pagesRegistry.kt deleted file mode 100644 index 3e02f770..00000000 --- a/app/src/main/java/com/garnegsoft/hubs/ui/screens/main/pagesRegistry.kt +++ /dev/null @@ -1,4 +0,0 @@ -package com.garnegsoft.hubs.ui.screens.main - -import androidx.compose.runtime.Composable - diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/screens/offline/OfflineArticleCard.kt b/app/src/main/java/com/garnegsoft/hubs/ui/screens/offline/OfflineArticleCard.kt index e6d6291c..1c79e784 100644 --- a/app/src/main/java/com/garnegsoft/hubs/ui/screens/offline/OfflineArticleCard.kt +++ b/app/src/main/java/com/garnegsoft/hubs/ui/screens/offline/OfflineArticleCard.kt @@ -19,17 +19,18 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil.compose.AsyncImage import com.garnegsoft.hubs.R +import com.garnegsoft.hubs.api.AsyncGifImage import com.garnegsoft.hubs.api.article.offline.OfflineArticleSnippet import com.garnegsoft.hubs.api.utils.formatTime import com.garnegsoft.hubs.api.utils.placeholderColorLegacy -import com.garnegsoft.hubs.ui.common.ArticleCardStyle -import com.garnegsoft.hubs.ui.common.defaultArticleCardStyle +import com.garnegsoft.hubs.ui.common.feedCards.article.ArticleCardStyle +import com.garnegsoft.hubs.ui.common.feedCards.article.ArticleCardStyle.Companion.defaultArticleCardStyle @Composable fun OfflineArticleCard( article: OfflineArticleSnippet, onClick: () -> Unit, - style: ArticleCardStyle = defaultArticleCardStyle() + style: ArticleCardStyle ) { Column( modifier = Modifier @@ -42,7 +43,7 @@ fun OfflineArticleCard( article.authorName?.let { Row(verticalAlignment = Alignment.CenterVertically) { if (article.authorAvatarUrl != null) { - AsyncImage( + AsyncGifImage( modifier = Modifier .size(style.authorAvatarSize) .clip(style.innerElementsShape), @@ -121,7 +122,7 @@ fun OfflineArticleCard( ) if (article.thumbnailUrl != null) { Spacer(modifier = Modifier.height(4.dp)) - AsyncImage( + AsyncGifImage( modifier = Modifier .fillMaxWidth() .aspectRatio(16f / 9f) diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/screens/offline/OfflineArticleScreen.kt b/app/src/main/java/com/garnegsoft/hubs/ui/screens/offline/OfflineArticleScreen.kt index abf5b8cf..85f05124 100644 --- a/app/src/main/java/com/garnegsoft/hubs/ui/screens/offline/OfflineArticleScreen.kt +++ b/app/src/main/java/com/garnegsoft/hubs/ui/screens/offline/OfflineArticleScreen.kt @@ -1,8 +1,14 @@ package com.garnegsoft.hubs.ui.screens.offline +import android.content.Context 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.Spacer 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.LazyColumn import androidx.compose.foundation.lazy.items @@ -13,47 +19,101 @@ import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewmodel.compose.viewModel +import com.garnegsoft.hubs.api.article.offline.OfflineArticle +import com.garnegsoft.hubs.api.article.offline.OfflineArticleSnippet +import com.garnegsoft.hubs.api.article.offline.OfflineArticlesDatabase import com.garnegsoft.hubs.api.article.offline.offlineArticlesDatabase +import com.garnegsoft.hubs.ui.common.feedCards.article.ArticleCardStyle +import kotlinx.coroutines.flow.Flow + +class OfflineArticleScreenViewModel(context: Context) : ViewModel() { + private val dao = OfflineArticlesDatabase.getDb(context).articlesDao() + val articles: Flow> = dao.getAllSnippetsSortedByIdDesc() +} @Composable fun OfflineArticlesScreen( - onBack: () -> Unit, - onArticleClick: (articleId: Int) -> Unit + onBack: () -> Unit, + onArticleClick: (articleId: Int) -> Unit ) { - val articlesDao = LocalContext.current.offlineArticlesDatabase.articlesDao() - - val articles by articlesDao.getAllSnippetsSortedByIdDesc().collectAsState(initial = emptyList()) - - Scaffold( - topBar = { - TopAppBar( - title = { Text("Сохраненные публикации")}, - elevation = 0.dp, - navigationIcon = { - IconButton(onClick = onBack) { - Icon(imageVector = Icons.Default.ArrowBack, contentDescription = "back") - } - } - ) - - } - ) { - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(it), - verticalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = PaddingValues(8.dp) - ) { - items( - items = articles, - key = { it.articleId } - ) { - OfflineArticleCard(article = it, onClick = { onArticleClick(it.articleId) }) - } - } - } + + val context = LocalContext.current + val viewModel = viewModel { OfflineArticleScreenViewModel(context) } + + val articles by viewModel.articles.collectAsState(initial = null) + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Сохраненные публикации") }, + elevation = 0.dp, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(imageVector = Icons.Default.ArrowBack, contentDescription = "back") + } + } + ) + + } + ) { + val cardsStyle = ArticleCardStyle.defaultArticleCardStyle() + cardsStyle?.let { style -> + articles?.let { articlesList -> + if (articlesList.isEmpty()) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(it) + .padding(32.dp) + ){ + Column(modifier = Modifier.align(Alignment.Center),) { + Text( + modifier = Modifier.fillMaxWidth(), + text = "Нет скачанных статей", + style = MaterialTheme.typography.subtitle1, + textAlign = TextAlign.Center + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + textAlign = TextAlign.Center, + color = MaterialTheme.colors.onBackground.copy(0.5f), + text = "Для того, чтобы скачать статью вы можете зайти на статью и нажать на иконку скачивания справа сверху или после долгого нажатия на кнопку добавления в закладки в лентах нажать на кнопку с той же иконкой. Второй способ работает только после входа в аккаунт", + ) + } + + } + } + else { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(it), + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(8.dp) + ) { + items( + items = articlesList, + key = { it.articleId } + ) { + OfflineArticleCard( + article = it, + onClick = { onArticleClick(it.articleId) }, + style = style + ) + } + } + } + } + } + + } } \ No newline at end of file diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/screens/search/SearchScreen.kt b/app/src/main/java/com/garnegsoft/hubs/ui/screens/search/SearchScreen.kt index 158e5c56..adbc550a 100644 --- a/app/src/main/java/com/garnegsoft/hubs/ui/screens/search/SearchScreen.kt +++ b/app/src/main/java/com/garnegsoft/hubs/ui/screens/search/SearchScreen.kt @@ -50,6 +50,9 @@ import com.garnegsoft.hubs.api.rememberCollapsingContentState import com.garnegsoft.hubs.api.user.list.UserSnippet import com.garnegsoft.hubs.api.user.list.UsersListController import com.garnegsoft.hubs.ui.common.* +import com.garnegsoft.hubs.ui.common.feedCards.company.CompanyCard +import com.garnegsoft.hubs.ui.common.feedCards.hub.HubCard +import com.garnegsoft.hubs.ui.common.feedCards.user.UserCard import com.garnegsoft.hubs.ui.common.snippetsPages.ArticlesListPageWithFilter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -146,21 +149,23 @@ fun SearchScreen( imeAction = ImeAction.Search ), keyboardActions = KeyboardActions { - keyboardController?.hide() - if (searchTextValue.startsWith(".id")) { - if (searchTextValue.drop(3).isDigitsOnly()) - onArticleClicked(searchTextValue.drop(3).toInt()) - } else { - currentQuery = searchTextValue - - - viewModel.articlesListModel.editFilter( - ArticlesSearchFilter( - order = (viewModel.articlesListModel.filter.value as ArticlesSearchFilter).order, - query = currentQuery + if (searchTextValue.isNotBlank()) { + keyboardController?.hide() + if (searchTextValue.startsWith(".id")) { + if (searchTextValue.drop(3).isDigitsOnly()) + onArticleClicked(searchTextValue.drop(3).toInt()) + } else { + currentQuery = searchTextValue + + + viewModel.articlesListModel.editFilter( + ArticlesSearchFilter( + order = (viewModel.articlesListModel.filter.value as ArticlesSearchFilter).order, + query = currentQuery + ) ) - ) - showPages = true + showPages = true + } } }, singleLine = true, diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/screens/settings/ArticleScreenSettings.kt b/app/src/main/java/com/garnegsoft/hubs/ui/screens/settings/ArticleScreenSettings.kt index 7a5154b3..b9489f32 100644 --- a/app/src/main/java/com/garnegsoft/hubs/ui/screens/settings/ArticleScreenSettings.kt +++ b/app/src/main/java/com/garnegsoft/hubs/ui/screens/settings/ArticleScreenSettings.kt @@ -20,16 +20,17 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.datastore.preferences.core.edit import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel +import coil.compose.AsyncImage +import coil.compose.rememberAsyncImagePainter import com.garnegsoft.hubs.R +import com.garnegsoft.hubs.api.article.Article import com.garnegsoft.hubs.api.dataStore.HubsDataStore import com.garnegsoft.hubs.api.utils.placeholderColorLegacy -import com.garnegsoft.hubs.settingsDataStore -import com.garnegsoft.hubs.api.dataStore.settingsDataStoreFlow import com.garnegsoft.hubs.ui.common.TitledColumn +import com.garnegsoft.hubs.ui.screens.article.HubsRow import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch @@ -37,16 +38,19 @@ import kotlinx.coroutines.launch class ArticleScreenSettingsScreenViewModel : ViewModel() { - val Context.fontSize: Flow + val Context.fontSize: Flow get() { - return this.settingsDataStoreFlow(HubsDataStore.Settings.Keys.ArticleScreen.FontSize) + return HubsDataStore.Settings + .getValueFlow(this, HubsDataStore.Settings.ArticleScreen.FontSize) } fun Context.setFontSize(size: Float) { viewModelScope.launch(Dispatchers.IO) { - settingsDataStore.edit { - it.set(HubsDataStore.Settings.Keys.ArticleScreen.FontSize, size) - } + HubsDataStore.Settings.edit( + this@setFontSize, + HubsDataStore.Settings.ArticleScreen.FontSize, + size + ) } } @@ -60,9 +64,7 @@ fun ArticleScreenSettingsScreen( val viewModel = viewModel() val context = LocalContext.current - - - + val defaultFontSize = MaterialTheme.typography.body1.fontSize.value val originalFontSize: Float? by with(viewModel) { context.fontSize.collectAsState(initial = defaultFontSize) } var fontSize: Float? by remember { mutableStateOf(null) } @@ -218,26 +220,13 @@ fun ArticleScreenSettingsScreen( Row( verticalAlignment = Alignment.CenterVertically, ) { - Icon( - modifier = Modifier - .size(34.dp) - .border( - width = 2.dp, - color = placeholderColorLegacy("Boomburum"), - shape = RoundedCornerShape(8.dp) - ) - .background( - Color.White, - shape = RoundedCornerShape(8.dp) - ) - .padding(2.dp), - painter = painterResource(id = R.drawable.user_avatar_placeholder), - contentDescription = "", - tint = placeholderColorLegacy("Boomburum") - ) + AsyncImage( + modifier = Modifier.size(34.dp).clip(RoundedCornerShape(8.dp)), + model = "https://assets.habr.com/habr-web/img/avatars/012.png", contentDescription = null) + Spacer(modifier = Modifier.width(4.dp)) Text( - text = "Boomburum", fontWeight = FontWeight.W600, + text = "squada", fontWeight = FontWeight.W600, fontSize = 14.sp, color = MaterialTheme.colors.onBackground ) @@ -245,7 +234,7 @@ fun ArticleScreenSettingsScreen( Spacer(modifier = Modifier.size(8.dp)) Text( text = "Пример публикации", - fontSize = 22.sp, + fontSize = ((fontSize ?: originalFontSize ?: defaultFontSize) + 4f).sp, fontWeight = FontWeight.W700, color = MaterialTheme.colors.onBackground ) @@ -270,12 +259,18 @@ fun ArticleScreenSettingsScreen( ) } Spacer(modifier = Modifier.width(12.dp)) - Text( - text = "Разработка, Программирование*, Habr, Jetpack Compose*", style = TextStyle( - color = Color.Gray, - fontWeight = FontWeight.W500 - ) + HubsRow( + hubs = listOf( + Article.Hub("", false, false, "Разработка", null), + Article.Hub("", true, false, "Программирование", Article.Hub.RelatedData(true)), + Article.Hub("", false, false, "Habr", null), + Article.Hub("", true, false, "Jetpack Compose", null) + + ), + onHubClicked = { }, + onCompanyClicked = { } ) + Spacer(modifier = Modifier.height(8.dp)) val animatedLineHeightFactor by animateFloatAsState(targetValue = lineHeightFactor) diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/screens/settings/FeedSettingsScreen.kt b/app/src/main/java/com/garnegsoft/hubs/ui/screens/settings/FeedSettingsScreen.kt new file mode 100644 index 00000000..869192b0 --- /dev/null +++ b/app/src/main/java/com/garnegsoft/hubs/ui/screens/settings/FeedSettingsScreen.kt @@ -0,0 +1,363 @@ +package com.garnegsoft.hubs.ui.screens.settings + +import android.content.Context +import androidx.compose.animation.animateContentSize +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.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.BottomSheetScaffold +import androidx.compose.material.Checkbox +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.Icon +import androidx.compose.material.IconButton +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Slider +import androidx.compose.material.Text +import androidx.compose.material.TopAppBar +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.rememberBottomSheetScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +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.clip +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel +import com.garnegsoft.hubs.R +import com.garnegsoft.hubs.api.EditorVersion +import com.garnegsoft.hubs.api.PublicationComplexity +import com.garnegsoft.hubs.api.PostType +import com.garnegsoft.hubs.api.article.Article +import com.garnegsoft.hubs.api.article.list.ArticleSnippet +import com.garnegsoft.hubs.api.dataStore.HubsDataStore +import com.garnegsoft.hubs.ui.common.feedCards.article.ArticleCard +import com.garnegsoft.hubs.ui.common.feedCards.article.ArticleCardStyle +import com.garnegsoft.hubs.ui.screens.settings.cards.SettingsCardItem +import kotlinx.coroutines.launch + + +class FeedSettingsScreenViewModel : ViewModel() { + + fun ChangeShowImageSetting(context: Context, show: Boolean) { + viewModelScope.launch { + HubsDataStore.Settings.edit(context, HubsDataStore.Settings.ArticleCard.ShowImage, show) + + } + } + + fun ChangeShowTextSnippetSetting(context: Context, show: Boolean) { + viewModelScope.launch { + HubsDataStore.Settings.edit( + context, + HubsDataStore.Settings.ArticleCard.ShowTextSnippet, + show + ) + + } + + } + + fun ChangeTitleFontSizeSetting(context: Context, size: Float) { + viewModelScope.launch { + HubsDataStore.Settings.edit( + context, + HubsDataStore.Settings.ArticleCard.TitleFontSize, + size + ) + } + } + + fun ChangeSnippetMaxLinesSetting(context: Context, lines: Int) { + viewModelScope.launch { + HubsDataStore.Settings.edit(context, HubsDataStore.Settings.ArticleCard.TextSnippetMaxLines, lines) + } + } + + fun ChangeSnippetFontSize(context: Context, size: Float) { + viewModelScope.launch { + HubsDataStore.Settings.edit(context, HubsDataStore.Settings.ArticleCard.TextSnippetFontSize, size) + } + } +} + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun FeedSettingsScreen( + onBack: () -> Unit +) { + val viewModel = viewModel() + val context = LocalContext.current + + val scaffoldState = rememberBottomSheetScaffoldState() + BottomSheetScaffold( + modifier = Modifier.imePadding(), + scaffoldState = scaffoldState, + topBar = { + TopAppBar( + elevation = 0.dp, + title = { Text(text = "Лента") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon(imageVector = Icons.Default.ArrowBack, contentDescription = null) + } + }) + }, + sheetContent = { + Column(modifier = Modifier.fillMaxHeight(0.5f)) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(16.dp) + ) { + Spacer( + modifier = Modifier + .align(Alignment.BottomCenter) + .height(4.dp) + .width(32.dp) + .clip(RoundedCornerShape(50)) + .background(MaterialTheme.colors.onBackground.copy(0.15f)) + ) + } + Spacer(modifier = Modifier.height(16.dp)) + Column( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp) + .padding(bottom = 20.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + val showImage by HubsDataStore.Settings.getValueFlow( + LocalContext.current, + HubsDataStore.Settings.ArticleCard.ShowImage + ).collectAsState(initial = null) + showImage?.let { + SettingsCardItem( + title = "Показывать КДПВ", + onClick = { viewModel.ChangeShowImageSetting(context, !it) }, + trailingIcon = { + Checkbox(checked = it, onCheckedChange = { + viewModel.ChangeShowImageSetting(context, it) + }) + } + ) + } + + val showTextSnippet by HubsDataStore.Settings.getValueFlow( + LocalContext.current, + HubsDataStore.Settings.ArticleCard.ShowTextSnippet + ).collectAsState(initial = null) + showTextSnippet?.let { + SettingsCardItem( + title = "Показать начало статьи", + onClick = { + viewModel.ChangeShowTextSnippetSetting(context, !it) + }, + trailingIcon = { + Checkbox(checked = it, onCheckedChange = { + viewModel.ChangeShowTextSnippetSetting(context, it) + }) + }) + + } + + val titleFontSize by HubsDataStore.Settings.getValueFlow( + LocalContext.current, + HubsDataStore.Settings.ArticleCard.TitleFontSize + ).collectAsState(initial = null) + + titleFontSize?.let { + Column(modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp)) { + var sliderValue by remember { mutableStateOf(it) } + Text(text = "Размер шрифта заголовка: ${"%.1f".format(sliderValue)}") + Slider( + value = sliderValue, + valueRange = 16f..28f, + steps = 5, + onValueChange = { + sliderValue = it + }, + onValueChangeFinished = { + viewModel.ChangeTitleFontSizeSetting(context, sliderValue) + }, + colors = ArticleScreenSettingsSliderColors + ) + } + + + } + + val snippetFontSize by HubsDataStore.Settings.getValueFlow( + LocalContext.current, + HubsDataStore.Settings.ArticleCard.TextSnippetFontSize + ).collectAsState(initial = null) + + snippetFontSize?.let { + Column( + modifier = Modifier.padding(4.dp) + ) { + var sliderValue by remember { mutableStateOf(it) } + + Text(text = "Размер шрифта начала статьи: ${"%.0f".format(sliderValue)}",) + Slider( + value = sliderValue, + enabled = showTextSnippet ?: false, + valueRange = 12f..24f, + steps = 5, + onValueChange = { + sliderValue = it + }, + onValueChangeFinished = { + viewModel.ChangeSnippetFontSize(context, sliderValue) + }, + colors = ArticleScreenSettingsSliderColors + ) + } + } + + val snippetMaxLines by HubsDataStore.Settings.getValueFlow( + LocalContext.current, + HubsDataStore.Settings.ArticleCard.TextSnippetMaxLines + ).collectAsState(initial = null) + + snippetMaxLines?.let { + Column( + modifier = Modifier.padding(4.dp) + ) { + var sliderValue by remember { mutableStateOf(it.toFloat()) } + + Text(text = "Показывать строк начала статьи: ${"%.0f".format(sliderValue)}",) + Slider( + value = sliderValue, + enabled = showTextSnippet ?: false, + valueRange = 2f..10f, + steps = 7, + onValueChange = { + sliderValue = it + }, + onValueChangeFinished = { + viewModel.ChangeSnippetMaxLinesSetting(context, sliderValue.toInt()) + }, + colors = ArticleScreenSettingsSliderColors + ) + } + } + + } + + } + }, + sheetElevation = 8.dp, + sheetPeekHeight = 80.dp, + sheetShape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp) + ) { + Column( + modifier = Modifier.padding(it) + ) { + val fakeArticles = listOf(TestArticle, SecondArticle) + ArticleCardStyle.defaultArticleCardStyle()?.let { style -> + LazyColumn( + contentPadding = PaddingValues(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(fakeArticles) { + + Box(modifier = Modifier.animateContentSize()) { + ArticleCard( + article = it, + onClick = { /*TODO*/ }, + onAuthorClick = { /*TODO*/ }, + onCommentsClick = { /*TODO*/ }, + style = style + ) + Box(modifier = Modifier + .matchParentSize() + .pointerInput(Unit) {}) + } + } + } + } + } + } +} + +val TestArticle + @Composable + get() = ArticleSnippet( + 0, + "вчера, 06:32", + false, + "Что делать дальше?", + EditorVersion.FirstVersion, + PostType.Unknown, + listOf(), + Article.Author( + "squada", + avatarUrl = "https://assets.habr.com/habr-web/img/avatars/012.png" + ), + Article.Statistics(5, 9, 2000, 4, 0, 0), + listOf( + Article.Hub("", true, false, "Программирование", null), + Article.Hub("", true, false, ".NET", Article.Hub.RelatedData(true)), + Article.Hub("", false, false, "Карьера в IT", null) + ), + stringResource(id = R.string.settings_first_article_snippet_text), + "https://megapicture.com/non-existing-picture.png", + null, + 15, + PublicationComplexity.Low, + null, + false +) + + +val SecondArticle + @Composable + get() = ArticleSnippet( + 0, + "вчера, 03:28", + false, + "Игорь уничтожил нам серверную!!!", + EditorVersion.FirstVersion, + PostType.Unknown, + listOf(), + Article.Author( + "gohonor", + avatarUrl = "https://assets.habr.com/habr-web/img/avatars/016.png" + ), + Article.Statistics(45, 12, 4000, -14, 0, 0), + listOf( + Article.Hub("", false, false, "Системное администрирование", null), + ), + stringResource(R.string.settings_second_article_snippet_text), + "https://megapicture.com/non-existing-picture.png", + null, + 10, + PublicationComplexity.None, + null, + false +) \ No newline at end of file diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/screens/settings/LogFileProvider.kt b/app/src/main/java/com/garnegsoft/hubs/ui/screens/settings/LogFileProvider.kt new file mode 100644 index 00000000..2a9c6a15 --- /dev/null +++ b/app/src/main/java/com/garnegsoft/hubs/ui/screens/settings/LogFileProvider.kt @@ -0,0 +1,8 @@ +package com.garnegsoft.hubs.ui.screens.settings + +import androidx.core.content.FileProvider +import com.garnegsoft.hubs.R + +class LogFileProvider : FileProvider(R.xml.log_file_path) { + +} \ No newline at end of file diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/screens/settings/NoRipple.kt b/app/src/main/java/com/garnegsoft/hubs/ui/screens/settings/NoRipple.kt new file mode 100644 index 00000000..b7c48b0d --- /dev/null +++ b/app/src/main/java/com/garnegsoft/hubs/ui/screens/settings/NoRipple.kt @@ -0,0 +1,14 @@ +package com.garnegsoft.hubs.ui.screens.settings + +import androidx.compose.material.ripple.RippleAlpha +import androidx.compose.material.ripple.RippleTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +val noRipple = object : RippleTheme { + @Composable + override fun defaultColor() = Color.Unspecified + + @Composable + override fun rippleAlpha(): RippleAlpha = RippleAlpha(0.0f, 0.0f, 0.0f, 0.0f) +} \ No newline at end of file diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/com/garnegsoft/hubs/ui/screens/settings/SettingsScreen.kt index e08aecd9..35897330 100644 --- a/app/src/main/java/com/garnegsoft/hubs/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/com/garnegsoft/hubs/ui/screens/settings/SettingsScreen.kt @@ -1,62 +1,89 @@ package com.garnegsoft.hubs.ui.screens.settings import android.content.Context -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.isSystemInDarkTheme +import android.content.Intent +import android.os.Build import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.* import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack -import androidx.compose.material.icons.filled.ArrowForward -import androidx.compose.material.ripple.LocalRippleTheme -import androidx.compose.material.ripple.RippleAlpha -import androidx.compose.material.ripple.RippleTheme -import androidx.compose.material.ripple.rememberRipple import androidx.compose.runtime.* -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.datastore.preferences.core.edit +import androidx.core.content.FileProvider import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewmodel.compose.viewModel +import com.garnegsoft.hubs.BuildConfig import com.garnegsoft.hubs.api.dataStore.HubsDataStore -import com.garnegsoft.hubs.settingsDataStore -import com.garnegsoft.hubs.api.dataStore.settingsDataStoreFlow -import com.garnegsoft.hubs.api.dataStore.settingsDataStoreFlowWithDefault -import com.garnegsoft.hubs.ui.common.BasicTitledColumn +import com.garnegsoft.hubs.authorized +import com.garnegsoft.hubs.ui.screens.settings.cards.AppearanceSettingsCard +import com.garnegsoft.hubs.ui.screens.settings.cards.ExperimentalFeaturesSettingsCard +import com.garnegsoft.hubs.ui.screens.settings.cards.OtherCard import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import java.io.File +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Locale class SettingsScreenViewModel : ViewModel() { - fun getTheme(context: Context): Flow { - return context.settingsDataStoreFlow(HubsDataStore.Settings.Keys.Theme) - .map { - it?.let { - HubsDataStore.Settings.Keys.ThemeModes.values().get(it) - } ?: HubsDataStore.Settings.Keys.ThemeModes.Undetermined - } + fun getTheme(context: Context): Flow { + return HubsDataStore.Settings + .getValueFlow(context, HubsDataStore.Settings.Theme.ColorSchemeMode) + .run { HubsDataStore.Settings.Theme.ColorSchemeMode.mapValues(this) } } - fun setTheme(context: Context, theme: HubsDataStore.Settings.Keys.ThemeModes) { + fun setTheme( + context: Context, + theme: HubsDataStore.Settings.Theme.ColorSchemeMode.ColorScheme + ) { viewModelScope.launch(Dispatchers.IO) { - context.settingsDataStore.edit { - it.set(HubsDataStore.Settings.Keys.Theme, theme.ordinal) + HubsDataStore.Settings.edit( + context, + HubsDataStore.Settings.Theme.ColorSchemeMode, + theme.ordinal + ) + } + } + + + fun captureLogsAndShare(context: Context) { + val dateFormat = SimpleDateFormat("ddMMyyHHmm", Locale.US) + val date = dateFormat.format(Calendar.getInstance().time) + val name = "Отчет об ошибке $date.txt" + + val info = """ + Версия приложения: ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE}) + Был авторизован: ${authorized} + Имя устройства: ${Build.BRAND} ${Build.MODEL} (${android.os.Build.DEVICE}) + Версия ОС (SDK): ${Build.VERSION.SDK_INT} + Процессор: ${Build.HARDWARE} + """.trimIndent() + + Runtime.getRuntime() + .exec("logcat -d") + .inputStream.use { input -> + + context.openFileOutput(name, Context.MODE_PRIVATE).use { + it.write(input.readBytes() + "\n".toByteArray() + info.toByteArray()) + + } } + val logsPath = File(context.filesDir, "") + val file = File(logsPath, name) + val logFileUri = FileProvider.getUriForFile(context, "com.garnegsoft.hubs.fileprovider", file) + val sendIntent = Intent(Intent.ACTION_SEND).apply { + type = "message/rfc822" + putExtra(Intent.EXTRA_EMAIL, arrayOf("garnegsoft@gmail.com")) + putExtra(Intent.EXTRA_SUBJECT, "Отчет об ошибке (логи)") + putExtra(Intent.EXTRA_STREAM, logFileUri) } + context.startActivity(Intent.createChooser(sendIntent, null)) + } } @@ -64,11 +91,9 @@ class SettingsScreenViewModel : ViewModel() { fun SettingsScreen( onBack: () -> Unit, onArticleScreenSettings: () -> Unit, + onFeedSettings: () -> Unit ) { val viewModel = viewModel() - val context = LocalContext.current - - val theme by viewModel.getTheme(context).collectAsState(initial = null) Scaffold( topBar = { @@ -86,13 +111,7 @@ fun SettingsScreen( ) }, ) { - val noRipple = object : RippleTheme { - @Composable - override fun defaultColor() = Color.Unspecified - - @Composable - override fun rippleAlpha(): RippleAlpha = RippleAlpha(0.0f, 0.0f, 0.0f, 0.0f) - } + Column( modifier = Modifier .padding(it) @@ -100,271 +119,13 @@ fun SettingsScreen( .padding(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { - theme?.let { - Column( - modifier = Modifier - .clip(RoundedCornerShape(26.dp)) - .background(MaterialTheme.colors.surface) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - BasicTitledColumn( - title = { - Text( - modifier = Modifier.padding(bottom = 12.dp), - text = "Внешний вид", style = MaterialTheme.typography.subtitle1 - ) - }, - divider = { - // Divider() - } - ) { - - Column( - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - // TODO: Create special item for settings. Replace checkboxes with switches in Material3 - val isSystemInDarkTheme = isSystemInDarkTheme() - var useSystemDefinedTheme by rememberSaveable { - mutableStateOf( - it == HubsDataStore.Settings.Keys.ThemeModes.SystemDefined || - it == HubsDataStore.Settings.Keys.ThemeModes.Undetermined - ) - } - val sharedInteractionSource = remember { MutableInteractionSource() } - var useDarkTheme by remember { - mutableStateOf( - if (it == HubsDataStore.Settings.Keys.ThemeModes.SystemDefined) { - isSystemInDarkTheme - } else it == HubsDataStore.Settings.Keys.ThemeModes.Dark - ) - } - - Row(modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(10.dp)) - .clickable( - interactionSource = sharedInteractionSource, - indication = rememberRipple() - ) { - useSystemDefinedTheme = !useSystemDefinedTheme - viewModel.setTheme( - context, - if (useSystemDefinedTheme) { - HubsDataStore.Settings.Keys.ThemeModes.SystemDefined - } else { - if (isSystemInDarkTheme) - HubsDataStore.Settings.Keys.ThemeModes.Dark - else - HubsDataStore.Settings.Keys.ThemeModes.Light - } - ) - } - .padding(start = 4.dp) - .height(48.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text(modifier = Modifier.weight(1f), text = "Системная тема") - CompositionLocalProvider(LocalRippleTheme provides noRipple) { - - Checkbox( - checked = useSystemDefinedTheme, - onCheckedChange = { - useSystemDefinedTheme = !useSystemDefinedTheme - viewModel.setTheme( - context, - if (useSystemDefinedTheme) { - HubsDataStore.Settings.Keys.ThemeModes.SystemDefined - } else { - if (isSystemInDarkTheme) - HubsDataStore.Settings.Keys.ThemeModes.Dark - else - HubsDataStore.Settings.Keys.ThemeModes.Light - } - ) - }, - interactionSource = sharedInteractionSource - ) - } - } - - val isDarkThemeInteractionSource = - remember { MutableInteractionSource() } - - LaunchedEffect( - key1 = useSystemDefinedTheme, - key2 = isSystemInDarkTheme, - block = { - when { - (useSystemDefinedTheme && isSystemInDarkTheme && !useDarkTheme) -> useDarkTheme = - true - - (useSystemDefinedTheme && !isSystemInDarkTheme && useDarkTheme) -> useDarkTheme = - false - } - }) - Row(modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(10.dp)) - .clickable( - enabled = !useSystemDefinedTheme, - interactionSource = isDarkThemeInteractionSource, - indication = rememberRipple() - ) { - useDarkTheme = !useDarkTheme - viewModel.setTheme( - context, - if (useDarkTheme) - HubsDataStore.Settings.Keys.ThemeModes.Dark - else - HubsDataStore.Settings.Keys.ThemeModes.Light - ) - } - .padding(start = 4.dp) - .heightIn(min = 48.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - modifier = Modifier - .weight(1f) - .alpha(if (useSystemDefinedTheme) 0.5f else 1f), - text = "Тёмная тема" - ) - CompositionLocalProvider(LocalRippleTheme provides noRipple) { - - Checkbox( - checked = useDarkTheme, - enabled = !useSystemDefinedTheme, - onCheckedChange = { - useDarkTheme = it - viewModel.setTheme( - context, - if (useDarkTheme) - HubsDataStore.Settings.Keys.ThemeModes.Dark - else - HubsDataStore.Settings.Keys.ThemeModes.Light - ) - }, - interactionSource = isDarkThemeInteractionSource - ) - } - } - - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(10.dp)) - .clickable(onClick = onArticleScreenSettings) - .padding(start = 4.dp) - .heightIn(min = 48.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Column(modifier = Modifier.weight(1f)) { - Text(text = "Внешний вид статьи") - Text( - text = "Размер шрифта, межстрочный интервал и т.д.", - fontSize = 11.sp, - color = MaterialTheme.colors.onSurface.copy(0.5f) - ) - } - Icon( - modifier = Modifier.padding(12.dp), - imageVector = Icons.Default.ArrowForward, - contentDescription = null - ) - } - } - } - - - } - } - val commentsDisplayMode by context.settingsDataStoreFlowWithDefault( - HubsDataStore.Settings.Keys.Comments.CommentsDisplayMode, - HubsDataStore.Settings.Keys.Comments.CommentsDisplayModes.Default.ordinal - ).collectAsState(initial = null) - - commentsDisplayMode?.let { - Column( - modifier = Modifier - .clip(RoundedCornerShape(26.dp)) - .background(MaterialTheme.colors.surface) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - BasicTitledColumn( - title = { - Text( - modifier = Modifier.padding(bottom = 12.dp), - text = "Комментарии", style = MaterialTheme.typography.subtitle1 - ) - }, - divider = { - // Divider() - } - ) { - val commentsModeSwitchInteractionSource = - remember { MutableInteractionSource() } - var useThreadsComments by remember { mutableStateOf(it == HubsDataStore.Settings.Keys.Comments.CommentsDisplayModes.Threads.ordinal) } - Column( - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - - Row(modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(10.dp)) - .clickable( - interactionSource = commentsModeSwitchInteractionSource, - indication = rememberRipple() - ) { - useThreadsComments = !useThreadsComments - viewModel.viewModelScope.launch { - context.settingsDataStore.edit { prefs -> - prefs.set( - HubsDataStore.Settings.Keys.Comments.CommentsDisplayMode, - if (useThreadsComments) HubsDataStore.Settings.Keys.Comments.CommentsDisplayModes.Threads.ordinal else HubsDataStore.Settings.Keys.Comments.CommentsDisplayModes.Default.ordinal - ) - } - } - } - .padding(start = 4.dp) - .height(48.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - modifier = Modifier.weight(1f), - text = "Скрывать ветки комментариев" - ) - - CompositionLocalProvider(LocalRippleTheme provides noRipple) { - Checkbox( - checked = useThreadsComments, - onCheckedChange = { - useThreadsComments = it - viewModel.viewModelScope.launch { - context.settingsDataStore.edit { prefs -> - prefs.set( - HubsDataStore.Settings.Keys.Comments.CommentsDisplayMode, - if (it) HubsDataStore.Settings.Keys.Comments.CommentsDisplayModes.Threads.ordinal else HubsDataStore.Settings.Keys.Comments.CommentsDisplayModes.Default.ordinal - ) - } - } - - }, - interactionSource = commentsModeSwitchInteractionSource - ) - } - } - - - } - } - - - } - } - - + AppearanceSettingsCard( + viewModel = viewModel, + onArticleScreenSettings = onArticleScreenSettings, + onFeedSettings = onFeedSettings + ) + ExperimentalFeaturesSettingsCard(viewModel = viewModel) + OtherCard(viewModel = viewModel) } } diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/screens/settings/cards/AppearanceSettingsCard.kt b/app/src/main/java/com/garnegsoft/hubs/ui/screens/settings/cards/AppearanceSettingsCard.kt new file mode 100644 index 00000000..0c4adfd9 --- /dev/null +++ b/app/src/main/java/com/garnegsoft/hubs/ui/screens/settings/cards/AppearanceSettingsCard.kt @@ -0,0 +1,213 @@ +package com.garnegsoft.hubs.ui.screens.settings.cards + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Checkbox +import androidx.compose.material.Icon +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowForward +import androidx.compose.material.ripple.LocalRippleTheme +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.garnegsoft.hubs.api.dataStore.HubsDataStore +import com.garnegsoft.hubs.ui.screens.settings.SettingsScreenViewModel +import com.garnegsoft.hubs.ui.screens.settings.noRipple + +@Composable +fun AppearanceSettingsCard( + viewModel: SettingsScreenViewModel, + onFeedSettings: () -> Unit, + onArticleScreenSettings: () -> Unit +) { + val context = LocalContext.current + val theme by viewModel.getTheme(context).collectAsState(initial = null) + theme.let { + SettingsCard(title = "Внешний вид") { + // TODO: Create special item for settings. Replace checkboxes with switches in Material3 + val isSystemInDarkTheme = isSystemInDarkTheme() + var useSystemDefinedTheme by rememberSaveable { + mutableStateOf( + it == HubsDataStore.Settings.Theme.ColorSchemeMode.ColorScheme.SystemDefined || + it == HubsDataStore.Settings.Theme.ColorSchemeMode.ColorScheme.Undetermined + ) + } + val sharedInteractionSource = remember { MutableInteractionSource() } + var useDarkTheme by remember { + mutableStateOf( + if (it == HubsDataStore.Settings.Theme.ColorSchemeMode.ColorScheme.SystemDefined) { + isSystemInDarkTheme + } else it == HubsDataStore.Settings.Theme.ColorSchemeMode.ColorScheme.Dark + ) + } + + Row(modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .clickable( + interactionSource = sharedInteractionSource, + indication = rememberRipple() + ) { + useSystemDefinedTheme = !useSystemDefinedTheme + viewModel.setTheme( + context, + if (useSystemDefinedTheme) { + HubsDataStore.Settings.Theme.ColorSchemeMode.ColorScheme.SystemDefined + } else { + if (isSystemInDarkTheme) + HubsDataStore.Settings.Theme.ColorSchemeMode.ColorScheme.Dark + else + HubsDataStore.Settings.Theme.ColorSchemeMode.ColorScheme.Light + } + ) + } + .padding(start = 4.dp) + .height(48.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(modifier = Modifier.weight(1f), text = "Системная тема") + CompositionLocalProvider(LocalRippleTheme provides noRipple) { + + Checkbox( + checked = useSystemDefinedTheme, + onCheckedChange = { + useSystemDefinedTheme = !useSystemDefinedTheme + viewModel.setTheme( + context, + if (useSystemDefinedTheme) { + HubsDataStore.Settings.Theme.ColorSchemeMode.ColorScheme.SystemDefined + } else { + if (isSystemInDarkTheme) + HubsDataStore.Settings.Theme.ColorSchemeMode.ColorScheme.Dark + else + HubsDataStore.Settings.Theme.ColorSchemeMode.ColorScheme.Light + } + ) + }, + interactionSource = sharedInteractionSource + ) + } + } + + val isDarkThemeInteractionSource = + remember { MutableInteractionSource() } + + LaunchedEffect( + key1 = useSystemDefinedTheme, + key2 = isSystemInDarkTheme, + block = { + when { + (useSystemDefinedTheme && isSystemInDarkTheme && !useDarkTheme) -> useDarkTheme = + true + + (useSystemDefinedTheme && !isSystemInDarkTheme && useDarkTheme) -> useDarkTheme = + false + } + }) + Row(modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .clickable( + enabled = !useSystemDefinedTheme, + interactionSource = isDarkThemeInteractionSource, + indication = rememberRipple() + ) { + useDarkTheme = !useDarkTheme + viewModel.setTheme( + context, + if (useDarkTheme) + HubsDataStore.Settings.Theme.ColorSchemeMode.ColorScheme.Dark + else + HubsDataStore.Settings.Theme.ColorSchemeMode.ColorScheme.Light + ) + } + .padding(start = 4.dp) + .heightIn(min = 48.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier + .weight(1f) + .alpha(if (useSystemDefinedTheme) 0.5f else 1f), + text = "Тёмная тема" + ) + CompositionLocalProvider(LocalRippleTheme provides noRipple) { + + Checkbox( + checked = useDarkTheme, + enabled = !useSystemDefinedTheme, + onCheckedChange = { + useDarkTheme = it + viewModel.setTheme( + context, + if (useDarkTheme) + HubsDataStore.Settings.Theme.ColorSchemeMode.ColorScheme.Dark + else + HubsDataStore.Settings.Theme.ColorSchemeMode.ColorScheme.Light + ) + }, + interactionSource = isDarkThemeInteractionSource + ) + } + } + + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .clickable(onClick = onArticleScreenSettings) + .padding(start = 4.dp) + .heightIn(min = 48.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text(text = "Внешний вид статьи") + Text( + text = "Размер шрифта, межстрочный интервал и т.д.", + fontSize = 11.sp, + color = MaterialTheme.colors.onSurface.copy(0.5f) + ) + } + Icon( + modifier = Modifier.padding(12.dp), + imageVector = Icons.Default.ArrowForward, + contentDescription = null + ) + } + SettingsCardItem( + title = "Внешний вид ленты", onClick = onFeedSettings, + trailingIcon = { + Icon( + modifier = Modifier.padding(12.dp), + imageVector = Icons.Default.ArrowForward, + contentDescription = null) + } + ) + } + + } +} \ No newline at end of file diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/screens/settings/cards/BaseSettingsCard.kt b/app/src/main/java/com/garnegsoft/hubs/ui/screens/settings/cards/BaseSettingsCard.kt new file mode 100644 index 00000000..762ef1db --- /dev/null +++ b/app/src/main/java/com/garnegsoft/hubs/ui/screens/settings/cards/BaseSettingsCard.kt @@ -0,0 +1,47 @@ +package com.garnegsoft.hubs.ui.screens.settings.cards + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import com.garnegsoft.hubs.ui.common.BasicTitledColumn + +@Composable +fun SettingsCard( + title: String, + content: @Composable ColumnScope.() -> Unit +) { + Column( + modifier = Modifier + .clip(RoundedCornerShape(26.dp)) + .background(MaterialTheme.colors.surface) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + BasicTitledColumn( + title = { + Text( + modifier = Modifier.padding(bottom = 12.dp), + text = title, style = MaterialTheme.typography.subtitle1 + ) + }, + divider = { + // Divider() + }, + + ) { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + content = content + ) + } + } +} diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/screens/settings/cards/BaseSettingsCardItem.kt b/app/src/main/java/com/garnegsoft/hubs/ui/screens/settings/cards/BaseSettingsCardItem.kt new file mode 100644 index 00000000..15d7aba1 --- /dev/null +++ b/app/src/main/java/com/garnegsoft/hubs/ui/screens/settings/cards/BaseSettingsCardItem.kt @@ -0,0 +1,44 @@ +package com.garnegsoft.hubs.ui.screens.settings.cards + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +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.shape.RoundedCornerShape +import androidx.compose.material.Text +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp + +@Composable +fun SettingsCardItem( + title: String, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + onClick: () -> Unit, + trailingIcon: @Composable () -> Unit = {}, + enabled: Boolean = true +) { + Row(modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .clickable( + interactionSource = interactionSource, + indication = rememberRipple(), + enabled = enabled, + onClick = onClick + ) + .padding(start = 4.dp) + .height(48.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(modifier = Modifier.weight(1f), text = title) + trailingIcon() + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/screens/settings/cards/ExperimentalFeaturesSettingsCard.kt b/app/src/main/java/com/garnegsoft/hubs/ui/screens/settings/cards/ExperimentalFeaturesSettingsCard.kt new file mode 100644 index 00000000..b5ba5eea --- /dev/null +++ b/app/src/main/java/com/garnegsoft/hubs/ui/screens/settings/cards/ExperimentalFeaturesSettingsCard.kt @@ -0,0 +1,109 @@ +package com.garnegsoft.hubs.ui.screens.settings.cards + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.shape.RoundedCornerShape +import androidx.compose.material.Checkbox +import androidx.compose.material.Text +import androidx.compose.material.ripple.LocalRippleTheme +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.collectAsState +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.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewModelScope +import com.garnegsoft.hubs.api.dataStore.HubsDataStore +import com.garnegsoft.hubs.ui.screens.settings.SettingsScreenViewModel +import com.garnegsoft.hubs.ui.screens.settings.noRipple +import kotlinx.coroutines.launch + + +@Composable +fun ExperimentalFeaturesSettingsCard( + viewModel: SettingsScreenViewModel +) { + val context = LocalContext.current + val commentsDisplayMode by HubsDataStore.Settings + .getValueFlow(context, HubsDataStore.Settings.CommentsDisplayMode) + .collectAsState(initial = null) + + commentsDisplayMode?.let { + SettingsCard( + title = "Экспериментальные функции" + ) { + val commentsModeSwitchInteractionSource = + remember { MutableInteractionSource() } + var useThreadsComments by remember { mutableStateOf(it == HubsDataStore.Settings.CommentsDisplayMode.CommentsDisplayModes.Threads.ordinal) } + Column( + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + + Row(modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .clickable( + interactionSource = commentsModeSwitchInteractionSource, + indication = rememberRipple() + ) { + useThreadsComments = !useThreadsComments + viewModel.viewModelScope.launch { + HubsDataStore.Settings + .edit( + context, + HubsDataStore.Settings.CommentsDisplayMode, + if (useThreadsComments) HubsDataStore.Settings.CommentsDisplayMode.CommentsDisplayModes.Threads.ordinal + else HubsDataStore.Settings.CommentsDisplayMode.CommentsDisplayModes.Default.ordinal + ) + } + } + .padding(start = 4.dp) + .height(48.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + modifier = Modifier.weight(1f), + text = "Скрывать ветки комментариев" + ) + + CompositionLocalProvider(LocalRippleTheme provides noRipple) { + Checkbox( + checked = useThreadsComments, + onCheckedChange = { + useThreadsComments = it + viewModel.viewModelScope.launch { + HubsDataStore.Settings + .edit( + context, + HubsDataStore.Settings.CommentsDisplayMode, + if (useThreadsComments) HubsDataStore.Settings.CommentsDisplayMode.CommentsDisplayModes.Threads.ordinal + else HubsDataStore.Settings.CommentsDisplayMode.CommentsDisplayModes.Default.ordinal + ) + } + + }, + interactionSource = commentsModeSwitchInteractionSource + ) + } + } + + + } + } + + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/screens/settings/cards/OtherCard.kt b/app/src/main/java/com/garnegsoft/hubs/ui/screens/settings/cards/OtherCard.kt new file mode 100644 index 00000000..b0c1e83f --- /dev/null +++ b/app/src/main/java/com/garnegsoft/hubs/ui/screens/settings/cards/OtherCard.kt @@ -0,0 +1,102 @@ +package com.garnegsoft.hubs.ui.screens.settings.cards + +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.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Button +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.TextButton +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.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.garnegsoft.hubs.ui.screens.settings.SettingsScreenViewModel + +@Composable +fun OtherCard( + viewModel: SettingsScreenViewModel +) { + val context = LocalContext.current + SettingsCard(title = "Другое") { + var showDialog by remember { mutableStateOf(false) } + ShareLogsDialog( + show = showDialog, + onDismiss = { showDialog = false }, + onDone = { + viewModel.captureLogsAndShare(context) + showDialog = false + }) + SettingsCardItem(title = "Отправить отчёт об ошибке", onClick = { showDialog = true }) + + } +} + +@Composable +fun ShareLogsDialog( + show: Boolean, + onDismiss: () -> Unit, + onDone: () -> Unit, +) { + if (show) { + Dialog( + properties = DialogProperties(true, true), + onDismissRequest = onDismiss + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(26.dp)) + .background(MaterialTheme.colors.surface) + .padding(20.dp) + ) { + Column(modifier = Modifier.height(IntrinsicSize.Min)) { + Text( + text = "Внимание!", + color = MaterialTheme.colors.onSurface, + style = MaterialTheme.typography.subtitle1 + ) + Spacer(modifier = Modifier.height(12.dp)) + Box(modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState())) { + Text(text = "Для отправки отчета выберите приложение почты во всплывающем меню.\n\nВ приложении почты уже будет введен адрес и прикреплен нужный файл. Обязательно опишите проблему с которой вы столкнулись!") + } + Spacer(modifier = Modifier.height(12.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + TextButton(onClick = onDismiss, contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp)) { + Text(text = "Отмена") + } + Spacer(modifier = Modifier.width(8.dp)) + Button( + onClick = onDone, elevation = null) { + Text(text = "Продолжить") + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/screens/user/LogoutConfirmDialog.kt b/app/src/main/java/com/garnegsoft/hubs/ui/screens/user/LogoutConfirmDialog.kt new file mode 100644 index 00000000..b68aa0ac --- /dev/null +++ b/app/src/main/java/com/garnegsoft/hubs/ui/screens/user/LogoutConfirmDialog.kt @@ -0,0 +1,80 @@ +package com.garnegsoft.hubs.ui.screens.user + +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.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.Button +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties + + +@Composable +fun LogoutConfirmDialog( + show: Boolean, + onDismiss: () -> Unit, + onProceed: () -> Unit, +) { + if (show) { + Dialog( + properties = DialogProperties(true, true), + onDismissRequest = onDismiss + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(26.dp)) + .background(MaterialTheme.colors.surface) + .padding(20.dp) + ) { + Column(modifier = Modifier.height(IntrinsicSize.Min)) { + Text( + text = "Вы хотите выйти?", + color = MaterialTheme.colors.onSurface, + style = MaterialTheme.typography.subtitle1 + ) +// Spacer(modifier = Modifier.height(12.dp)) +// Box(modifier = Modifier +// .weight(1f) +// .verticalScroll(rememberScrollState())) { +// Text(text = "") +// } + Spacer(modifier = Modifier.height(12.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + TextButton( + onClick = onDismiss, + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp) + ) { + Text(text = "Отмена") + } + Spacer(modifier = Modifier.width(8.dp)) + Button(onClick = onProceed, elevation = null) { + Text(text = "Выйти") + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/screens/user/UserArticlesFilter.kt b/app/src/main/java/com/garnegsoft/hubs/ui/screens/user/UserArticlesFilter.kt index aba3b5cf..026771df 100644 --- a/app/src/main/java/com/garnegsoft/hubs/ui/screens/user/UserArticlesFilter.kt +++ b/app/src/main/java/com/garnegsoft/hubs/ui/screens/user/UserArticlesFilter.kt @@ -1,11 +1,14 @@ package com.garnegsoft.hubs.ui.screens.user +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row import androidx.compose.material.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.unit.dp import com.garnegsoft.hubs.api.Filter import com.garnegsoft.hubs.ui.common.BaseFilterDialog import com.garnegsoft.hubs.ui.common.HubsFilterChip @@ -37,11 +40,13 @@ fun UserArticlesFilter( BaseFilterDialog(onDismiss = onDismiss, onDone = { onDone(UserArticlesFilter(showNews))}) { TitledColumn(title = "Тип публикаций") { - HubsFilterChip(selected = !showNews, onClick = { showNews = false }) { - Text(text = "Статьи") - } - HubsFilterChip(selected = showNews, onClick = { showNews = true }) { - Text(text = "Новости") + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + HubsFilterChip(selected = !showNews, onClick = { showNews = false }) { + Text(text = "Статьи") + } + HubsFilterChip(selected = showNews, onClick = { showNews = true }) { + Text(text = "Новости") + } } } } diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/screens/user/UserBookmarksFilter.kt b/app/src/main/java/com/garnegsoft/hubs/ui/screens/user/UserBookmarksFilter.kt index 8ba0465d..28fa256e 100644 --- a/app/src/main/java/com/garnegsoft/hubs/ui/screens/user/UserBookmarksFilter.kt +++ b/app/src/main/java/com/garnegsoft/hubs/ui/screens/user/UserBookmarksFilter.kt @@ -1,11 +1,14 @@ package com.garnegsoft.hubs.ui.screens.user +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row import androidx.compose.material.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.unit.dp import com.garnegsoft.hubs.api.Filter import com.garnegsoft.hubs.ui.common.BaseFilterDialog import com.garnegsoft.hubs.ui.common.HubsFilterChip @@ -54,17 +57,19 @@ fun UserBookmarksFilter( } ) { TitledColumn(title = "Тип закладок") { - HubsFilterChip( - selected = bookmarksType == UserBookmarksFilter.Bookmarks.Articles, - onClick = { bookmarksType = UserBookmarksFilter.Bookmarks.Articles } - ) { - Text(text = "Статьи") - } - HubsFilterChip( - selected = bookmarksType == UserBookmarksFilter.Bookmarks.News, - onClick = { bookmarksType = UserBookmarksFilter.Bookmarks.News } - ) { - Text(text = "Новости") + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + HubsFilterChip( + selected = bookmarksType == UserBookmarksFilter.Bookmarks.Articles, + onClick = { bookmarksType = UserBookmarksFilter.Bookmarks.Articles } + ) { + Text(text = "Статьи") + } + HubsFilterChip( + selected = bookmarksType == UserBookmarksFilter.Bookmarks.News, + onClick = { bookmarksType = UserBookmarksFilter.Bookmarks.News } + ) { + Text(text = "Новости") + } } // TODO: Add comments bookmarks // HubsFilterChip( diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/screens/user/UserProfile.kt b/app/src/main/java/com/garnegsoft/hubs/ui/screens/user/UserProfile.kt index e65f181e..bb21f15d 100644 --- a/app/src/main/java/com/garnegsoft/hubs/ui/screens/user/UserProfile.kt +++ b/app/src/main/java/com/garnegsoft/hubs/ui/screens/user/UserProfile.kt @@ -28,541 +28,546 @@ import com.garnegsoft.hubs.api.user.UserController import com.garnegsoft.hubs.api.utils.placeholderColorLegacy import com.garnegsoft.hubs.ui.common.AsyncSvgImage import com.garnegsoft.hubs.ui.common.BasicTitledColumn +import com.garnegsoft.hubs.ui.common.HubChip import com.garnegsoft.hubs.ui.common.RefreshableContainer import com.garnegsoft.hubs.ui.common.TitledColumn import com.garnegsoft.hubs.ui.screens.article.ElementSettings import com.garnegsoft.hubs.ui.screens.article.RenderHtml import com.garnegsoft.hubs.ui.theme.DefaultRatingIndicatorColor -import com.garnegsoft.hubs.ui.theme.RatingNegative -import com.garnegsoft.hubs.ui.theme.RatingPositive +import com.garnegsoft.hubs.ui.theme.RatingNegativeColor +import com.garnegsoft.hubs.ui.theme.RatingPositiveColor import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @OptIn(ExperimentalLayoutApi::class) @Composable internal fun UserProfile( - isAppUser: Boolean, - onUserLogout: (() -> Unit)? = null, - onHubClick: (alias: String) -> Unit, - onWorkPlaceClick: (alias: String) -> Unit, - scrollState: ScrollState, - viewModel: UserScreenViewModel + isAppUser: Boolean, + onUserLogout: (() -> Unit)? = null, + onHubClick: (alias: String) -> Unit, + onWorkPlaceClick: (alias: String) -> Unit, + scrollState: ScrollState, + viewModel: UserScreenViewModel ) { - val userState by viewModel.user.observeAsState() - val isRefreshing by viewModel.isRefreshingUser.observeAsState(false) - RefreshableContainer(onRefresh = viewModel::refreshUser, refreshing = isRefreshing) { - userState?.let { user -> - Column( - modifier = Modifier - .fillMaxSize() - .verticalScroll(scrollState) - ) { - Column( - modifier = Modifier.padding(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(26.dp)) - .background(MaterialTheme.colors.surface) - .padding(12.dp) - ) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(12.dp) - ) { - if (user.avatarUrl != null) { - AsyncImage( - model = user.avatarUrl, - modifier = Modifier - .size(65.dp) - .align(Alignment.Center) - .clip( - RoundedCornerShape(12.dp) - ) - .background(Color.White), - contentDescription = "" - ) - } else { - Icon( - modifier = Modifier - .size(65.dp) - .background(Color.White, shape = RoundedCornerShape(12.dp)) - .border( - width = 4.dp, - color = placeholderColorLegacy(user.alias), - shape = RoundedCornerShape(12.dp) - ) - .align(Alignment.Center) - .padding(5.dp), - painter = painterResource(id = R.drawable.user_avatar_placeholder), - contentDescription = "", - tint = placeholderColorLegacy(user.alias) - ) - } - } - Box(modifier = Modifier.fillMaxWidth()) { - if (user.fullname != null) - Text( - modifier = Modifier.align(Alignment.Center), - text = "${user.fullname}\n@${user.alias}", - fontWeight = FontWeight.W700, - fontSize = 26.sp, - textAlign = TextAlign.Center - ) - else - Text( - modifier = Modifier.align(Alignment.Center), - text = "@${user.alias}", - fontWeight = FontWeight.W700, - fontSize = 26.sp, - textAlign = TextAlign.Center - ) - } - if (user.isReadonly) - Box( - modifier = Modifier - .fillMaxWidth() - .padding(0.dp) - ) { - Text( - modifier = Modifier.align(Alignment.Center), - text = "Read Only", - fontWeight = FontWeight.W400, - color = MaterialTheme.colors.onSurface.copy(0.2f), - textAlign = TextAlign.Center - ) - } - if (user.speciality != null) - Box( - modifier = Modifier - .fillMaxWidth() - .padding( - bottom = 8.dp, - start = 8.dp, - end = 8.dp, - top = 8.dp - ) - ) { - Text( - modifier = Modifier.align(Alignment.Center), - text = user.speciality, - fontWeight = FontWeight.W500, - color = MaterialTheme.colors.onSurface.copy(ContentAlpha.disabled), - textAlign = TextAlign.Center - ) - } - Row( - modifier = Modifier - .fillMaxWidth() - .padding(8.dp), - horizontalArrangement = Arrangement.SpaceEvenly - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = user.score.toString(), - fontSize = 24.sp, - fontWeight = FontWeight.W600, - color = - if (user.score > 0) - RatingPositive - else - if (user.score == 0) - MaterialTheme.colors.onSurface - else - RatingNegative - ) - Text( - text = "Карма", - color = MaterialTheme.colors.onSurface.copy(ContentAlpha.disabled) - ) - } - Column( - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = user.rating.toString(), - fontSize = 24.sp, - fontWeight = FontWeight.W600, - color = DefaultRatingIndicatorColor - ) - Text( - text = "Рейтинг", - color = MaterialTheme.colors.onSurface.copy(ContentAlpha.disabled) - ) - } - } - if (!isAppUser && !user.isReadonly) { - user.relatedData?.let { - var subscribed by rememberSaveable { - mutableStateOf(it.isSubscribed) - } - val subscriptionCoroutineScope = rememberCoroutineScope() - Box(modifier = Modifier - .padding(8.dp) - .height(45.dp) - .fillMaxWidth() - .clip(RoundedCornerShape(10.dp)) - .background(if (subscribed) Color(0xFF4CB025) else Color.Transparent) - .border( - width = 1.dp, - shape = RoundedCornerShape(10.dp), - color = if (subscribed) Color.Transparent else Color( - 0xFF4CB025 - ) - ) - .clickable { - subscriptionCoroutineScope.launch(Dispatchers.IO) { - subscribed = !subscribed - subscribed = UserController.subscription(user.alias) - } - } - ) { - Text( - modifier = Modifier.align(Alignment.Center), - text = if (subscribed) "Вы подписаны" else "Подписаться", - color = if (subscribed) Color.White else Color(0xFF4CB025) - ) - } - } - } - - } - val note by viewModel.note.observeAsState() - if (!isAppUser && note?.text != null) { - Column( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(26.dp)) - .background(MaterialTheme.colors.surface) - .padding(8.dp) - ) { - BasicTitledColumn( - title = { - Text( - modifier = Modifier.padding(12.dp), - text = "Заметка", style = MaterialTheme.typography.subtitle1 - ) - }, - divider = { - // Divider() - } - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding( - start = 12.dp, - end = 12.dp, - bottom = 12.dp - ) - .clip(RoundedCornerShape(10.dp)) - .background( - MaterialTheme.colors.onSurface.copy(0.04f) - ) - .padding(8.dp) - ) { - Text( - color = MaterialTheme.colors.onSurface.copy(0.75f), - text = note?.text ?: "" - ) - } - } - } - } - val whoIs by viewModel.whoIs.observeAsState() - whoIs?.let { whoIs -> - if (!whoIs.aboutHtml.isNullOrBlank() || whoIs.badges.isNotEmpty() || whoIs.invite != null || whoIs.contacts.isNotEmpty()) { - Column( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(26.dp)) - .background(MaterialTheme.colors.surface) - .padding(8.dp) - ) { - BasicTitledColumn(title = { - Text( - modifier = Modifier.padding(12.dp), - text = "Описание", - style = MaterialTheme.typography.subtitle1 - ) - }, divider = { + val userState by viewModel.user.observeAsState() + val isRefreshing by viewModel.isRefreshingUser.observeAsState(false) + RefreshableContainer(onRefresh = viewModel::refreshUser, refreshing = isRefreshing) { + userState?.let { user -> + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(scrollState) + ) { + Column( + modifier = Modifier.padding(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(26.dp)) + .background(MaterialTheme.colors.surface) + .padding(12.dp) + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp) + ) { + if (user.avatarUrl != null) { + AsyncImage( + model = user.avatarUrl, + modifier = Modifier + .size(65.dp) + .align(Alignment.Center) + .clip( + RoundedCornerShape(12.dp) + ) + .background(Color.White), + contentDescription = "" + ) + } else { + Icon( + modifier = Modifier + .size(65.dp) + .background(Color.White, shape = RoundedCornerShape(12.dp)) + .border( + width = 4.dp, + color = placeholderColorLegacy(user.alias), + shape = RoundedCornerShape(12.dp) + ) + .align(Alignment.Center) + .padding(5.dp), + painter = painterResource(id = R.drawable.user_avatar_placeholder), + contentDescription = "", + tint = placeholderColorLegacy(user.alias) + ) + } + } + Box(modifier = Modifier.fillMaxWidth()) { + if (user.fullname != null) + Text( + modifier = Modifier.align(Alignment.Center), + text = "${user.fullname}\n@${user.alias}", + fontWeight = FontWeight.W700, + fontSize = 26.sp, + textAlign = TextAlign.Center + ) + else + Text( + modifier = Modifier.align(Alignment.Center), + text = "@${user.alias}", + fontWeight = FontWeight.W700, + fontSize = 26.sp, + textAlign = TextAlign.Center + ) + } + if (user.isReadonly) + Box( + modifier = Modifier + .fillMaxWidth() + .padding(0.dp) + ) { + Text( + modifier = Modifier.align(Alignment.Center), + text = "Read Only", + fontWeight = FontWeight.W400, + color = MaterialTheme.colors.onSurface.copy(0.2f), + textAlign = TextAlign.Center + ) + } + if (user.speciality != null) + Box( + modifier = Modifier + .fillMaxWidth() + .padding( + bottom = 8.dp, + start = 8.dp, + end = 8.dp, + top = 8.dp + ) + ) { + Text( + modifier = Modifier.align(Alignment.Center), + text = user.speciality, + fontWeight = FontWeight.W500, + color = MaterialTheme.colors.onSurface.copy(ContentAlpha.disabled), + textAlign = TextAlign.Center + ) + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), + horizontalArrangement = Arrangement.SpaceEvenly + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = user.score.toString(), + fontSize = 24.sp, + fontWeight = FontWeight.W600, + color = + if (user.score > 0) + RatingPositiveColor + else + if (user.score == 0) + MaterialTheme.colors.onSurface + else + RatingNegativeColor + ) + Text( + text = "Карма", + color = MaterialTheme.colors.onSurface.copy(ContentAlpha.disabled) + ) + } + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = user.rating.toString(), + fontSize = 24.sp, + fontWeight = FontWeight.W600, + color = DefaultRatingIndicatorColor + ) + Text( + text = "Рейтинг", + color = MaterialTheme.colors.onSurface.copy(ContentAlpha.disabled) + ) + } + } + if (!isAppUser && !user.isReadonly) { + user.relatedData?.let { + var subscribed by rememberSaveable { + mutableStateOf(it.isSubscribed) + } + val subscriptionCoroutineScope = rememberCoroutineScope() + Box(modifier = Modifier + .padding(8.dp) + .height(45.dp) + .fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background(if (subscribed) Color(0xFF4CB025) else Color.Transparent) + .border( + width = 1.dp, + shape = RoundedCornerShape(10.dp), + color = if (subscribed) Color.Transparent else Color( + 0xFF4CB025 + ) + ) + .clickable { + subscriptionCoroutineScope.launch(Dispatchers.IO) { + subscribed = !subscribed + subscribed = UserController.subscription(user.alias) + } + } + ) { + Text( + modifier = Modifier.align(Alignment.Center), + text = if (subscribed) "Вы подписаны" else "Подписаться", + color = if (subscribed) Color.White else Color(0xFF4CB025) + ) + } + } + } + + } + val note by viewModel.note.observeAsState() + if (!isAppUser && note?.text != null) { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(26.dp)) + .background(MaterialTheme.colors.surface) + .padding(8.dp) + ) { + BasicTitledColumn( + title = { + Text( + modifier = Modifier.padding(12.dp), + text = "Заметка", style = MaterialTheme.typography.subtitle1 + ) + }, + divider = { } + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding( + start = 12.dp, + end = 12.dp, + bottom = 12.dp + ) + .clip(RoundedCornerShape(10.dp)) + .background( + MaterialTheme.colors.onSurface.copy(0.04f) + ) + .padding(8.dp) + ) { + Text( + color = MaterialTheme.colors.onSurface.copy(0.75f), + text = note?.text ?: "" + ) + } + } + } + } + val whoIs by viewModel.whoIs.observeAsState() + val hubs by viewModel.subscribedHubs.observeAsState() + + if ((whoIs != null && (!whoIs?.aboutHtml.isNullOrBlank() || whoIs!!.badges.isNotEmpty() + || whoIs!!.invite != null || whoIs!!.contacts.isNotEmpty())) + || !hubs?.list.isNullOrEmpty() + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(26.dp)) + .background(MaterialTheme.colors.surface) + .padding(8.dp) + ) { + BasicTitledColumn(title = { + Text( + modifier = Modifier.padding(12.dp), + text = "Описание", + style = MaterialTheme.typography.subtitle1 + ) + }, divider = { // Divider() - }) { - Column( - modifier = Modifier.padding( - start = 12.dp, - end = 12.dp, - bottom = 12.dp, - top = 4.dp - ), - verticalArrangement = Arrangement.spacedBy(20.dp) - ) { - whoIs.badges.let { - TitledColumn(title = "Значки") { - FlowRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - it.forEach { Badge(title = it.title) } - } - } - } - - whoIs.aboutHtml?.let { - if (it.isNotBlank()) { - TitledColumn(title = "О Себе") { - RenderHtml( - html = it, - elementSettings = remember { - ElementSettings( - fontSize = 16.sp, - lineHeight = 16.sp, - fitScreenWidth = false - ) - }) - } - } - } - - whoIs.invite?.let { - TitledColumn(title = "Приглашен") { - Text(text = "${it.inviteDate} по приглашению от ${it.inviterAlias ?: "НЛО"}") - } - } - - whoIs.let { - if (it.contacts.size > 0) { - TitledColumn(title = "Контакты") { - Column( - verticalArrangement = Arrangement.spacedBy(4.dp) - ) { - val context = LocalContext.current - it.contacts.forEach { - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .background( - MaterialTheme.colors.onSurface.copy( - 0.04f - ) - ) - .clickable { - context.startActivity( - Intent( - Intent.ACTION_VIEW, - Uri.parse(it.url) - ) - ) - } - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - if (it.faviconUrl != null && it.faviconUrl.isNotBlank()) { - AsyncSvgImage( - modifier = Modifier - .size(24.dp) - .clip( - RoundedCornerShape(4.dp) - ) - .background(if (MaterialTheme.colors.isLight) Color.Transparent else MaterialTheme.colors.onSurface), - data = it.faviconUrl, - revertColorsOnDarkTheme = false, - contentDescription = it.title, - contentScale = ContentScale.Fit - ) - } else { - Icon( - modifier = Modifier.size(24.dp), - painter = painterResource(id = R.drawable.website_favicon_placeholder), - contentDescription = "website", - tint = MaterialTheme.colors.onSurface.copy( - 0.4f - ) - ) - } - - Spacer(modifier = Modifier.width(12.dp)) - - Text(it.title) - } - } - } - } - } - } - - val hubs by viewModel.subscribedHubs.observeAsState() - hubs?.let { - if (it.list.size > 0) { - Column() { - TitledColumn(title = "Состоит в хабах") { - FlowRow( - horizontalArrangement = Arrangement.spacedBy( - 8.dp - ) - ) { - it.list.forEach { - HubChip(hub = it) { - onHubClick(it.alias) - } - } - } - } - if (viewModel.moreHubsAvailable) { - TextButton( - onClick = { - viewModel.loadSubscribedHubs() - } - ) { - Text( - "Показать ещё", - color = MaterialTheme.colors.secondary, - letterSpacing = 0.sp - ) - } - } - } - } - } - - } - } - } - } - } - - Column( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(26.dp)) - .background(MaterialTheme.colors.surface) - .padding(8.dp) - ) { - BasicTitledColumn(title = { - Text( - modifier = Modifier.padding(12.dp), - text = "Информация", style = MaterialTheme.typography.subtitle1 - ) - }, divider = { + }) { + Column( + modifier = Modifier.padding( + start = 12.dp, + end = 12.dp, + bottom = 12.dp, + top = 4.dp + ), + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + whoIs?.let { whoIs -> + whoIs.badges.let { + + TitledColumn(title = "Значки") { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + it.forEach { Badge(title = it.title) } + } + } + } + + whoIs.aboutHtml?.let { + if (it.isNotBlank()) { + TitledColumn(title = "О Себе") { + RenderHtml( + html = it, + elementSettings = remember { + ElementSettings( + fontSize = 16.sp, + lineHeight = 16.sp, + fitScreenWidth = false + ) + }) + } + } + } + + whoIs.invite?.let { + TitledColumn(title = "Приглашен") { + Text(text = "${it.inviteDate} по приглашению от ${it.inviterAlias ?: "НЛО"}") + } + } + + whoIs.let { + if (it.contacts.size > 0) { + TitledColumn(title = "Контакты") { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + val context = LocalContext.current + it.contacts.forEach { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background( + MaterialTheme.colors.onSurface.copy( + 0.04f + ) + ) + .clickable { + context.startActivity( + Intent( + Intent.ACTION_VIEW, + Uri.parse(it.url) + ) + ) + } + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (it.faviconUrl != null && it.faviconUrl.isNotBlank()) { + AsyncSvgImage( + modifier = Modifier + .size(24.dp) + .clip( + RoundedCornerShape(4.dp) + ) + .background(if (MaterialTheme.colors.isLight) Color.Transparent else MaterialTheme.colors.onSurface), + data = it.faviconUrl, + revertColorsOnDarkTheme = false, + contentDescription = it.title, + contentScale = ContentScale.Fit + ) + } else { + Icon( + modifier = Modifier.size(24.dp), + painter = painterResource(id = R.drawable.website_favicon_placeholder), + contentDescription = "website", + tint = MaterialTheme.colors.onSurface.copy( + 0.4f + ) + ) + } + + Spacer(modifier = Modifier.width(12.dp)) + + Text(it.title) + } + } + } + } + } + } + + + } + hubs?.let { + if (it.list.size > 0) { + Column() { + TitledColumn(title = "Состоит в хабах") { + FlowRow( + horizontalArrangement = Arrangement.spacedBy( + 8.dp + ) + ) { + it.list.forEach { + HubChip(hub = it) { + onHubClick(it.alias) + } + } + } + } + if (viewModel.moreHubsAvailable) { + TextButton( + onClick = { + viewModel.loadSubscribedHubs() + } + ) { + Text( + "Показать ещё", + color = MaterialTheme.colors.secondary, + letterSpacing = 0.sp + ) + } + } + } + } + } + + } + } + } + } + + Column( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(26.dp)) + .background(MaterialTheme.colors.surface) + .padding(8.dp) + ) { + BasicTitledColumn(title = { + Text( + modifier = Modifier.padding(12.dp), + text = "Информация", style = MaterialTheme.typography.subtitle1 + ) + }, divider = { // Divider() - }) { - Column( - modifier = Modifier.padding( - start = 12.dp, - end = 12.dp, - bottom = 12.dp, - top = 4.dp - ), - verticalArrangement = Arrangement.spacedBy(20.dp) - ) { - TitledColumn( - title = "Место в рейтинге" - ) { - Text( - text = if (user.ratingPosition == null) "Не участвует" else user.ratingPosition.toString() + "-й", - ) - } - - user.location?.let { - TitledColumn(title = "Откуда") { - Text( - text = it, - ) - } - } - - if (user.workPlaces.size > 0) { - TitledColumn(title = "Работает в") { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - user.workPlaces.forEach { - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .background( - MaterialTheme.colors.onSurface.copy( - 0.04f - ) - ) - .clickable { - onWorkPlaceClick(it.alias) - } - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text(it.title) - } - } - } - } - } - - if (user.birthday != null) { - - TitledColumn(title = "Дата рождения") { - Text( - text = user.birthday, - ) - } - } - - TitledColumn(title = "Дата регистрации") { - Text( - text = user.registrationDate, - ) - } - - if (user.lastActivityDate != null) { - - TitledColumn(title = "Активность") { - Text( - text = user.lastActivityDate - ) - } - } - - - } - } - } - if (isAppUser) { - Card( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(26.dp)) - .clickable(onClick = onUserLogout!!), - elevation = 0.dp, - shape = RoundedCornerShape(26.dp), - backgroundColor = MaterialTheme.colors.surface, - ) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(12.dp) - ) { - Text( - modifier = Modifier.align(Alignment.Center), - text = "Выйти", - color = MaterialTheme.colors.error - ) - } - } - } - } - - } - } - } - if (userState == null){ - Box(modifier = Modifier.fillMaxSize()){ - CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) - } - } - + }) { + Column( + modifier = Modifier.padding( + start = 12.dp, + end = 12.dp, + bottom = 12.dp, + top = 4.dp + ), + verticalArrangement = Arrangement.spacedBy(20.dp) + ) { + TitledColumn( + title = "Место в рейтинге" + ) { + Text( + text = if (user.ratingPosition == null) "Не участвует" else user.ratingPosition.toString() + "-й", + ) + } + + user.location?.let { + TitledColumn(title = "Откуда") { + Text( + text = it, + ) + } + } + + if (user.workPlaces.size > 0) { + TitledColumn(title = "Работает в") { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + user.workPlaces.forEach { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background( + MaterialTheme.colors.onSurface.copy( + 0.04f + ) + ) + .clickable { + onWorkPlaceClick(it.alias) + } + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text(it.title) + } + } + } + } + } + + if (user.birthday != null) { + + TitledColumn(title = "Дата рождения") { + Text( + text = user.birthday, + ) + } + } + + TitledColumn(title = "Дата регистрации") { + Text( + text = user.registrationDate, + ) + } + + if (user.lastActivityDate != null) { + + TitledColumn(title = "Активность") { + Text( + text = user.lastActivityDate + ) + } + } + + + } + } + } + if (isAppUser) { + Card( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(26.dp)) + .clickable(onClick = onUserLogout!!), + elevation = 0.dp, + shape = RoundedCornerShape(26.dp), + backgroundColor = MaterialTheme.colors.surface, + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp) + ) { + Text( + modifier = Modifier.align(Alignment.Center), + text = "Выйти", + color = MaterialTheme.colors.error + ) + } + } + } + } + + } + } + } + if (userState == null) { + Box(modifier = Modifier.fillMaxSize()) { + CircularProgressIndicator(modifier = Modifier.align(Alignment.Center)) + } + } + } diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/screens/user/UserScreen.kt b/app/src/main/java/com/garnegsoft/hubs/ui/screens/user/UserScreen.kt index c1c5a480..b210424b 100755 --- a/app/src/main/java/com/garnegsoft/hubs/ui/screens/user/UserScreen.kt +++ b/app/src/main/java/com/garnegsoft/hubs/ui/screens/user/UserScreen.kt @@ -25,7 +25,7 @@ import com.garnegsoft.hubs.api.comment.list.CommentSnippet import com.garnegsoft.hubs.api.rememberCollapsingContentState import com.garnegsoft.hubs.api.utils.formatLongNumbers import com.garnegsoft.hubs.ui.common.* -import com.garnegsoft.hubs.ui.common.snippetsPages.ArticlesListPage +import com.garnegsoft.hubs.ui.common.feedCards.comment.CommentCard import com.garnegsoft.hubs.ui.common.snippetsPages.ArticlesListPageWithFilter import com.garnegsoft.hubs.ui.common.snippetsPages.CommentsListPage import com.garnegsoft.hubs.ui.common.snippetsPages.CommonPageWithFilter @@ -74,17 +74,19 @@ fun UserScreen( IconButton( onClick = { - val sendIntent = Intent(Intent.ACTION_SEND) - sendIntent.putExtra( - Intent.EXTRA_TEXT, - if (viewModel.user.value?.fullname != null) - "${viewModel.user.value!!.fullname} — https://habr.com/ru/users/${viewModel.user.value!!.alias}/" - else - "${viewModel.user.value!!.alias} — https://habr.com/ru/users/${viewModel.user.value!!.alias}/" - ) - sendIntent.setType("text/plain") - val shareIntent = Intent.createChooser(sendIntent, null) - context.startActivity(shareIntent) + viewModel.user.value?.let { user -> + val sendIntent = Intent(Intent.ACTION_SEND) + sendIntent.putExtra( + Intent.EXTRA_TEXT, + if (user.fullname != null) + "${user.fullname} (@${user.alias}) — https://habr.com/ru/users/${user.alias}/" + else + "${user.alias} — https://habr.com/ru/users/${user.alias}/" + ) + sendIntent.setType("text/plain") + val shareIntent = Intent.createChooser(sendIntent, null) + context.startActivity(shareIntent) + } }) { Icon(imageVector = Icons.Outlined.Share, contentDescription = "") } @@ -193,8 +195,7 @@ fun UserScreen( } ) } - } - else { + } else { CommonPageWithFilter( listModel = viewModel.commentsBookmarksModel, filterDialog = { defaultValues, onDismiss, onDone -> @@ -210,7 +211,12 @@ fun UserScreen( ) { CommentCard( comment = it, - onCommentClick = { onCommentClicked(it.parentPost.id, it.id) }, + onCommentClick = { + onCommentClicked( + it.parentPost.id, + it.id + ) + }, onAuthorClick = { onUserClicked(it.author.alias) }, onParentPostClick = { onArticleClicked(it.parentPost.id) }) } @@ -286,18 +292,31 @@ fun UserScreen( if (pagesMap.size > 1) { HabrScrollableTabRow(pagerState = pagerState, tabs = tabs) { index, title -> when { - title.startsWith("Профиль") -> { ScrollUpMethods.scrollNormalList(profilePageScrollState) } + title.startsWith("Профиль") -> { + ScrollUpMethods.scrollNormalList(profilePageScrollState) + } + title.startsWith("Публикации") -> { articlesFilterContentState.show() ScrollUpMethods.scrollLazyList(articlesLazyListState) } - title.startsWith("Комментарии") -> { ScrollUpMethods.scrollLazyList(commentsLazyListState) } + + title.startsWith("Комментарии") -> { + ScrollUpMethods.scrollLazyList(commentsLazyListState) + } + title.startsWith("Закладки") -> { bookmarksFilterContentState.show() ScrollUpMethods.scrollLazyList(bookmarksLazyListState) } - title.startsWith("Подписчики") -> { ScrollUpMethods.scrollLazyList(followersLazyListState) } - title.startsWith("Подписки") -> { ScrollUpMethods.scrollLazyList(subscriptionsLazyListState) } + + title.startsWith("Подписчики") -> { + ScrollUpMethods.scrollLazyList(followersLazyListState) + } + + title.startsWith("Подписки") -> { + ScrollUpMethods.scrollLazyList(subscriptionsLazyListState) + } } } diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/theme/Color.kt b/app/src/main/java/com/garnegsoft/hubs/ui/theme/Color.kt index 5195721a..6b51ce9e 100644 --- a/app/src/main/java/com/garnegsoft/hubs/ui/theme/Color.kt +++ b/app/src/main/java/com/garnegsoft/hubs/ui/theme/Color.kt @@ -2,19 +2,16 @@ package com.garnegsoft.hubs.ui.theme import androidx.compose.ui.graphics.Color -val Purple200 = Color(0xFFBB86FC) -val Purple500 = Color(0xFF6200EE) -val Purple700 = Color(0xFF3700B3) -val Teal200 = Color(0xFF03DAC5) val DefaultRatingIndicatorColor = Color(0xFFE719A9) val HubInvestmentIndicatorColor = Color(0xFF4CBE51) - val SecondaryColor = Color(0xFF_52_64_74) val PrimaryColor = Color(0xFF_30_3B_44) val PrimaryVariantColor = Color(0xFF_20_2A_32) val SecondaryVariantColor = Color(0xFF628DA8) -val RatingPositive = Color(0xFF4CBE51) -val RatingNegative = Color(0xFFC43333) +val RatingPositiveColor = Color(0xFF4CBE51) +val RatingNegativeColor = Color(0xFFC43333) + +val HubSubscribedColor = Color(0xE351A843) diff --git a/app/src/main/java/com/garnegsoft/hubs/ui/theme/Theme.kt b/app/src/main/java/com/garnegsoft/hubs/ui/theme/Theme.kt index 456eeec8..ce4e81a1 100644 --- a/app/src/main/java/com/garnegsoft/hubs/ui/theme/Theme.kt +++ b/app/src/main/java/com/garnegsoft/hubs/ui/theme/Theme.kt @@ -20,13 +20,13 @@ import androidx.compose.material3.MaterialTheme as Material3Theme private val DarkColorPalette = darkColors( primary = Color(0xFFE7E7E7), - primaryVariant = Color(0xFFDADEDF), - secondary = Color(0xFFECECEC), + primaryVariant = Color(0xFFE0E0E0), + secondary = Color(0xFFD3D3D3), onSecondary = Color(0x88FFFFFF), background = Color(32, 32, 32, 255), surface = Color(49, 49, 49, 255), onSurface = Color(0xFFDADADA), - onBackground = Color(0xFFC2C2C2), + onBackground = Color(0xFFD8D8D8), secondaryVariant = Color(0xFFB4B4B4), onError = Color.White ) diff --git a/app/src/main/res/drawable/bookmarks_shortcut_icon.xml b/app/src/main/res/drawable/bookmarks_shortcut_icon.xml new file mode 100644 index 00000000..fda1969a --- /dev/null +++ b/app/src/main/res/drawable/bookmarks_shortcut_icon.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/drawable/history.xml b/app/src/main/res/drawable/history.xml new file mode 100644 index 00000000..95294f14 --- /dev/null +++ b/app/src/main/res/drawable/history.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/logo2.xml b/app/src/main/res/drawable/logo2.xml index ff5916c1..ebbf336d 100644 --- a/app/src/main/res/drawable/logo2.xml +++ b/app/src/main/res/drawable/logo2.xml @@ -9,7 +9,7 @@ android:fillColor="#588ab2" android:strokeColor="#00000000"/> + + + diff --git a/app/src/main/res/drawable/settings_first_article_thumbnail.png b/app/src/main/res/drawable/settings_first_article_thumbnail.png new file mode 100644 index 00000000..c341fc68 Binary files /dev/null and b/app/src/main/res/drawable/settings_first_article_thumbnail.png differ diff --git a/app/src/main/res/drawable/views_icon.xml b/app/src/main/res/drawable/views_icon.xml index ea83639c..e4fd2635 100755 --- a/app/src/main/res/drawable/views_icon.xml +++ b/app/src/main/res/drawable/views_icon.xml @@ -10,7 +10,7 @@ android:strokeColor="#000000" android:strokeLineCap="square"/> + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/winter_launcher_icon_foreground.xml b/app/src/main/res/drawable/winter_launcher_icon_foreground.xml new file mode 100644 index 00000000..28290f59 --- /dev/null +++ b/app/src/main/res/drawable/winter_launcher_icon_foreground.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index 62172795..f6dda7b7 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,7 +1,7 @@ - - + + - + \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 26f6e166..bffca60d 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -33,4 +33,6 @@ Публикации Подписаться Вы подписаны + Сохраненные статьи + Сохраненные \ 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 b946d3cd..8d42a0c1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -34,5 +34,9 @@ Publications Follow Following + Saved Articles + Saved + В современном мире информационных технологий (IT) карьерный рост становится неотъемлемой частью профессиональной жизни специалистов. Развитие технологий и постоянные изменения в отрасли требуют от профессионалов IT глубокого понимания своей области, навыков адаптации и стратегического мышления. В данном реферате мы рассмотрим основные тенденции карьерного роста в IT-сфере, выявим факторы, влияющие на успешное продвижение по карьерной лестнице, и рассмотрим стратегии, которые могут помочь индивидуам достичь успеха в данной области. Тенденции карьерного роста в IT: Современная IT-сфера характеризуется высокой динамикой изменений. Технологии развиваются стремительно, и успешный профессионал должен постоянно совершенствовать свои навыки и следить за новыми тенденциями. Одной из ключевых тенденций является переход к облачным технологиям, искусственному интеллекту, большим данным и кибербезопасности. Профессионалы, специализирующиеся в этих областях, часто имеют больше возможностей для карьерного роста. Факторы успешного карьерного роста: Непрерывное обучение: Один из ключевых факторов успеха в IT - это готовность к постоянному обучению. Технологии быстро меняются, и специалистам приходится адаптироваться к новым требованиям. Участие в курсах, тренингах и сертификационных программых может значительно повысить конкурентоспособность. Специализация и экспертиза: Специализация в узкой области и развитие экспертизы позволяют выделиться среди конкурентов. Работа в специализированной области также может открывать дополнительные возможности для карьерного роста. Сетевые связи и общение: В мире IT большое значение имеют профессиональные связи. Участие в индустриальных мероприятиях, конференциях, и общение с коллегами и профессионалами из отрасли могут открыть новые перспективы для роста и развития.Лидерские навыки: Специалистам в IT необходимы не только технические навыки, но и лидерские качества. Умение эффективно управлять проектами, командами и принимать стратегические решения существенно влияет на карьерный успех.Стратегии карьерного роста: Разработка карьерного плана: Определение краткосрочных и долгосрочных целей, разработка плана и последовательное его выполнение помогут достигнуть успеха. Активное участие в сообществе: Участие в профессиональных сообществах и форумах позволяет узнавать о новых тенденциях, обмениваться опытом и создавать полезные контакты. Развитие мягких навыков: Помимо технических навыков, важно развивать мягкие навыки, такие как коммуникация, управление временем, и решение проблем. Поиск ментора: Общение с опытными профессионалами и наставничество могут быть весьма полезными в процессе карьерного роста. Карьерный рост в IT-сфере – это процесс, требующий постоянного самосовершенствования и адаптации к изменениям в отрасли. Специалисты, обладающие стратегическим мышлением, лидерскими качествами и готовые к постоянному обучению, находятся в лучшем положении для успешного продвижения по карьерной лестнице. Развитие технологий, акцент на инновациях и высокая конкуренция делают IT-сферу одним из наиболее динамичных и перспективных направлений для карьерного роста. + В тихом офисном здании, где монотонный гул вентиляции сливался с жужжанием компьютеров, работал неряшлевый системный администратор по имени Игорь. Игорь был тем человеком, у которого волосы всегда были в неразглаженных завитках, одежда вечно мятая, а копчик белым на брюках - его неотъемлемой чертой. Его рабочее место было настоящим хаосом: валялись бумаги, провода сплетались в путанице, клавиатура покрыта была слоем пыли, а в чашке, стоящей рядом, воду заменяли каким-то странным на вид жидким. Однажды, Игорь решил провести профилактику в серверной. Ему было неудобно, что его коллеги могли видеть внутреннее убранство того места, где он чувствовал себя в своей тарелке. \"Нет ничего хуже, чем думать, что тебя могут судить по внешнему виду, правда?\" – думал Игорь. Так началась его эпопея по уборке серверной. Он начал вытаскивать провода, пытаясь разобраться, что к чему. Однако его неаккуратные движения стоили ему дорого: нечаянно он задел стойку с серверами, и один из них начал медленно наклоняться. Не замечая изменений в устойчивости сервера, Игорь продолжал свою борьбу с проводами. И вот, в какой-то момент, сервер не выдержал и с треском упал на пол. Сильный удар вызвал короткое замыкание в электропитании. Тем временем, не замечая последствий своего творчества, Игорь разливал чашку кофе на сервера, оправдываясь перед собой, что это \"улучшит их работу\". В какой-то момент, когда он снова протягивался к своей цели, запах гари донесся до его носа. Огонь! Он вспыхнул в самом центре серверной. Паника охватила Игоря, но вместо того, чтобы вызывать помощь, он решил самостоятельно справиться с огнем. Он подбежал с чашкой воды и начал лить ее на пламя, не понимая, что вода и электроника – не самая лучшая комбинация. Привет смотрящему ресурсы) \ No newline at end of file diff --git a/app/src/main/res/xml-v25/shortcuts.xml b/app/src/main/res/xml-v25/shortcuts.xml new file mode 100644 index 00000000..84a9cf4b --- /dev/null +++ b/app/src/main/res/xml-v25/shortcuts.xml @@ -0,0 +1,16 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/log_file_path.xml b/app/src/main/res/xml/log_file_path.xml new file mode 100644 index 00000000..8a69e334 --- /dev/null +++ b/app/src/main/res/xml/log_file_path.xml @@ -0,0 +1,18 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/xml/shortcuts.xml b/app/src/main/res/xml/shortcuts.xml new file mode 100644 index 00000000..e67bb188 --- /dev/null +++ b/app/src/main/res/xml/shortcuts.xml @@ -0,0 +1,13 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/test/java/com/garnegsoft/hubs/ArticleResourceTests.kt b/app/src/test/java/com/garnegsoft/hubs/ArticleResourceTests.kt new file mode 100644 index 00000000..7416df3f --- /dev/null +++ b/app/src/test/java/com/garnegsoft/hubs/ArticleResourceTests.kt @@ -0,0 +1,45 @@ +package com.garnegsoft.hubs + +import ArticleController +import com.garnegsoft.hubs.api.HabrApi +import okhttp3.OkHttpClient +import org.jsoup.Jsoup +import org.jsoup.nodes.Document +import org.jsoup.nodes.Element +import org.junit.Assert.assertEquals +import org.junit.Test + +class ArticleResourceTests { + + @Test + fun getAllImages(){ + HabrApi.setHttpClient(OkHttpClient()) + + ArticleController.get(729112)?.let { + var urls = mutableListOf() + println("OG html: \n${it.contentHtml}") + var imageCounter = 0 + val result = Jsoup.parse(it.contentHtml).forEachNode { + if(it is Element && it.tagName() == "img"){ + var url = if (it.hasAttr("data-src")){ + it.attr("data-src") + } else { + it.attr("src") + } + + urls.add(url) + it.attr("data-src", "img$imageCounter.jpg") + + imageCounter++ + } + + } as Document + + println("\n\n\nafter jsoup: \n${result.body().html()}") + + println("\n\nOG URLS:\n${urls.joinToString("\n")}") + } + + } + +} \ No newline at end of file diff --git a/app/src/test/java/com/garnegsoft/hubs/ExampleUnitTest.kt b/app/src/test/java/com/garnegsoft/hubs/ExampleUnitTest.kt index d5b60ef8..37620101 100644 --- a/app/src/test/java/com/garnegsoft/hubs/ExampleUnitTest.kt +++ b/app/src/test/java/com/garnegsoft/hubs/ExampleUnitTest.kt @@ -1,10 +1,17 @@ package com.garnegsoft.hubs +import android.app.appsearch.GlobalSearchSession import com.garnegsoft.hubs.api.FilterPeriod +import com.garnegsoft.hubs.api.history.HistoryArticle import com.garnegsoft.hubs.ui.screens.main.NewsFilter +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.launch import org.junit.Test import org.junit.Assert.* +import java.util.Date /** * Example local unit test, which will execute on the development machine (host). @@ -60,6 +67,31 @@ class ExampleUnitTest { assertEquals("https://assets.habr.com/habr-web/img/avatars/076.png", endString) } - + + @Test + fun test_flowShit() { + val flow = getFlow() + GlobalScope.launch { + flow.collectLatest { + println(it) + } + } + while (true){} + } + + fun getFlow() = flow { + var counter = 0 + while (true) { + counter++ + emit(counter) + } + } + + @Test + fun test_serializer() { + val data = HistoryArticle(0, "aboba", "amongus", null) + println(data.toHistoryEntity().data) + } + } \ No newline at end of file diff --git a/build.gradle b/build.gradle index 2572cdcf..c6666da0 100644 --- a/build.gradle +++ b/build.gradle @@ -1,17 +1,17 @@ buildscript { ext { - compose_ui_version = '1.5.0' + compose_ui_version = '1.5.3' accompanist_version = '0.30.1' coil_version = "2.4.0" - room_version = "2.5.2" - navigation_version = "2.7.0" + room_version = "2.6.0" + navigation_version = "2.7.5" } }// Top-level build file where you can add configuration options common to all sub-projects/modules. plugins { id 'com.android.application' version '8.1.0' apply false id 'com.android.library' version '8.1.0' apply false - id 'org.jetbrains.kotlin.android' version '1.8.21' apply false - id 'org.jetbrains.kotlin.plugin.serialization' version '1.8.21' - id "org.jetbrains.kotlin.kapt" version "1.8.21" + id 'org.jetbrains.kotlin.android' version '1.9.10' apply false + id 'org.jetbrains.kotlin.plugin.serialization' version '1.9.10' + id "org.jetbrains.kotlin.kapt" version "1.9.10" id 'com.android.test' version '8.1.0' apply false } \ No newline at end of file diff --git a/gradle.properties b/gradle.properties index 0f4fb3a2..772048cd 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,27 +1,19 @@ -# Project-wide Gradle settings. -# IDE (e.g. Android Studio) users: -# Gradle settings configured through the IDE *will override* -# any settings specified in this file. -# For more details on how to configure your build environment visit +## For more details on how to configure your build environment visit # http://www.gradle.org/docs/current/userguide/build_environment.html +# # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. -org.gradle.jvmargs=-Xmx3g -Dfile.encoding=UTF-8 -XX:+UseParallelGC +# Default value: -Xmx1024m -XX:MaxPermSize=256m +# org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 +# # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # org.gradle.parallel=true -# AndroidX package structure to make it clearer which packages are bundled with the -# Android operating system, and which are packaged with your app's APK -# https://developer.android.com/topic/libraries/support-library/androidx-rn -android.useAndroidX=true -# Kotlin code style for this project: "official" or "obsolete": -kotlin.code.style=official -# Enables namespacing of each library's R class so that its R class includes only the -# resources declared in the library itself and none from the library's dependencies, -# thereby reducing the size of the R class for that library -android.nonTransitiveRClass=true +#Sat Sep 09 15:20:53 CEST 2023 android.defaults.buildfeatures.buildconfig=true android.nonFinalResIds=false -#org.gradle.unsafe.configuration-cache=true -#org.gradle.unsafe.configuration-cache-problems=warn \ No newline at end of file +android.nonTransitiveRClass=true +android.useAndroidX=true +kotlin.code.style=official +org.gradle.jvmargs=-Xmx2048M -Dkotlin.daemon.jvm.options\="-Xmx1024M" -Dfile.encoding\=UTF-8 -XX\:+UseParallelGC diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 63d65f9f..e807ebd8 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Tue May 30 17:13:41 CEST 2023 +#Sat Sep 09 15:23:57 CEST 2023 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-8.0-bin.zip distributionPath=wrapper/dists -zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-bin.zip zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists