diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 73e733f2ef8e..ec310f03d8f8 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -32,8 +32,9 @@ Fixes # ----- -## UI Changes Testing Checklist: +## Testing Checklist: +- [ ] WordPress.com sites and self-hosted Jetpack sites. - [ ] Portrait and landscape orientations. - [ ] Light and dark modes. - [ ] Fonts: Larger, smaller and bold text. diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index e2e517ec71ba..ae53f411fddc 100644 --- a/RELEASE-NOTES.txt +++ b/RELEASE-NOTES.txt @@ -1,9 +1,15 @@ *** PLEASE FOLLOW THIS FORMAT: [] [] +24.3 +----- + + 24.2 ----- * [**] Fix editor crash occurring on large posts [https://github.com/wordpress-mobile/WordPress-Android/pull/20046] * [*] [Jetpack-only] Site Monitoring: Add Metrics, PHP Logs, and Web Server Logs under Site Monitoring [https://github.com/wordpress-mobile/WordPress-Android/issues/20067] +* [**] Prevent images from temporarily disappearing when uploading media [https://github.com/WordPress/gutenberg/pull/57869] +* [***] [Jetpack-only] Reader: introduced new UI/UX for content navigation and filtering [https://github.com/wordpress-mobile/WordPress-Android/pull/19978] 24.1 ----- diff --git a/WordPress/build.gradle b/WordPress/build.gradle index c911d5d2c816..4b11a35570f3 100644 --- a/WordPress/build.gradle +++ b/WordPress/build.gradle @@ -423,8 +423,14 @@ dependencies { implementation "com.google.android.play:review:$googlePlayReviewVersion" implementation "com.google.android.play:review-ktx:$googlePlayReviewVersion" implementation "com.google.android.gms:play-services-auth:$googlePlayServicesAuthVersion" - implementation "com.google.android.gms:play-services-code-scanner:$googlePlayServicesCodeScannerVersion" - implementation "com.google.mlkit:barcode-scanning-common:$googleMLKitBarcodeScanningVersion" + implementation "com.google.mlkit:barcode-scanning-common:$googleMLKitBarcodeScanningCommonVersion" + implementation "com.google.mlkit:text-recognition:$googleMLKitTextRecognitionVersion" + implementation "com.google.mlkit:barcode-scanning:$googleMLKitBarcodeScanningVersion" + + // CameraX + implementation "androidx.camera:camera-camera2:$androidxCameraVersion" + implementation "androidx.camera:camera-lifecycle:$androidxCameraVersion" + implementation "androidx.camera:camera-view:$androidxCameraVersion" implementation "com.android.installreferrer:installreferrer:$androidInstallReferrerVersion" implementation "com.github.chrisbanes:PhotoView:$chrisbanesPhotoviewVersion" @@ -555,6 +561,9 @@ dependencies { androidTestImplementation "androidx.compose.ui:ui-test-junit4" implementation "androidx.compose.material3:material3:$androidxComposeMaterial3Version" + // Cascade - Compose nested menu + implementation "me.saket.cascade:cascade-compose:2.3.0" + // - Flipper debugImplementation ("com.facebook.flipper:flipper:$flipperVersion") { exclude group:'org.jetbrains.kotlinx', module:'kotlinx-serialization-json-jvm' diff --git a/WordPress/jetpack_metadata/release_notes.txt b/WordPress/jetpack_metadata/release_notes.txt index e7b4006244cd..2a490f10579f 100644 --- a/WordPress/jetpack_metadata/release_notes.txt +++ b/WordPress/jetpack_metadata/release_notes.txt @@ -1,6 +1,4 @@ -- Get notified when you’re working offline. -- Image block uploads pause/resume with internet connection. -- See custom gradient selections in the editor. -- Fixed forward/back arrows for right-to-left readers. -- Daily Prompt tags work properly. -- Tap Site Domain cards to manage domains. +* [**] Fix editor crash occurring on large posts [https://github.com/wordpress-mobile/WordPress-Android/pull/20046] +* [*] [Jetpack-only] Site Monitoring: Add Metrics, PHP Logs, and Web Server Logs under Site Monitoring [https://github.com/wordpress-mobile/WordPress-Android/issues/20067] +* [**] Prevent images from temporarily disappearing when uploading media [https://github.com/WordPress/gutenberg/pull/57869] +* [***] [Jetpack-only] Reader: introduced new UI/UX for content navigation and filtering [https://github.com/wordpress-mobile/WordPress-Android/pull/19978] diff --git a/WordPress/metadata/release_notes.txt b/WordPress/metadata/release_notes.txt index ab3a2cba51aa..d4aea576c6dd 100644 --- a/WordPress/metadata/release_notes.txt +++ b/WordPress/metadata/release_notes.txt @@ -1,4 +1,2 @@ -- Get notified when you’re working offline. -- Image block uploads pause when you lose internet and resume when you reconnect. -- Select a custom gradient in the editor and see a color indicator. -- “Forward” and “back” arrows are correct for right-to-left readers. +* [**] Fix editor crash occurring on large posts [https://github.com/wordpress-mobile/WordPress-Android/pull/20046] +* [**] Prevent images from temporarily disappearing when uploading media [https://github.com/WordPress/gutenberg/pull/57869] diff --git a/WordPress/src/androidTestJetpack/java/org/wordpress/android/e2e/ReaderTests.kt b/WordPress/src/androidTestJetpack/java/org/wordpress/android/e2e/ReaderTests.kt index 8f21e3c7c1c9..1787bcb54342 100644 --- a/WordPress/src/androidTestJetpack/java/org/wordpress/android/e2e/ReaderTests.kt +++ b/WordPress/src/androidTestJetpack/java/org/wordpress/android/e2e/ReaderTests.kt @@ -2,6 +2,7 @@ package org.wordpress.android.e2e import dagger.hilt.android.testing.HiltAndroidTest import org.junit.Before +import org.junit.Ignore import org.junit.Test import org.wordpress.android.e2e.pages.ReaderPage import org.wordpress.android.support.BaseTest @@ -18,6 +19,7 @@ class ReaderTests : BaseTest() { } @Test + @Ignore("Ignored for now considering the context of Reader IA feature that completely changes the Reader design") fun e2eNavigateThroughPosts() { ReaderPage() .tapFollowingTab() @@ -31,6 +33,7 @@ class ReaderTests : BaseTest() { } @Test + @Ignore("Ignored for now considering the context of Reader IA feature that completely changes the Reader design") fun e2eLikePost() { ReaderPage() .tapFollowingTab() @@ -43,6 +46,7 @@ class ReaderTests : BaseTest() { } @Test + @Ignore("Ignored for now considering the context of Reader IA feature that completely changes the Reader design") fun e2eBookmarkPost() { ReaderPage() .tapFollowingTab() diff --git a/WordPress/src/main/java/org/wordpress/android/datasets/ReaderPostTable.java b/WordPress/src/main/java/org/wordpress/android/datasets/ReaderPostTable.java index f1045252db95..6235ba23151c 100644 --- a/WordPress/src/main/java/org/wordpress/android/datasets/ReaderPostTable.java +++ b/WordPress/src/main/java/org/wordpress/android/datasets/ReaderPostTable.java @@ -658,14 +658,14 @@ public static String getOldestDateWithTag(final ReaderTag tag) { public static String getOldestPubDateInBlog(long blogId) { String sql = "SELECT date_published FROM tbl_posts" + " WHERE blog_id=? AND tag_name='' AND tag_type=0" - + " ORDER BY date_published LIMIT 1"; + + " ORDER BY datetime(date_published) LIMIT 1"; return SqlUtils.stringForQuery(ReaderDatabase.getReadableDb(), sql, new String[]{Long.toString(blogId)}); } public static String getOldestPubDateInFeed(long feedId) { String sql = "SELECT date_published FROM tbl_posts" + " WHERE feed_id=? AND tag_name='' AND tag_type=0" - + " ORDER BY date_published LIMIT 1"; + + " ORDER BY datetime(date_published) LIMIT 1"; return SqlUtils.stringForQuery(ReaderDatabase.getReadableDb(), sql, new String[]{Long.toString(feedId)}); } @@ -743,15 +743,15 @@ public static String getGapMarkerDateForTag(ReaderTag tag) { */ private static String getSortColumnForTag(ReaderTag tag) { if (tag.isPostsILike()) { - return "date_liked"; + return "datetime(date_liked)"; } else if (tag.isFollowedSites()) { - return "date_published"; + return "datetime(date_published)"; } else if (tag.tagType == ReaderTagType.SEARCH) { return "score"; } else if (tag.isTagTopic() || tag.isBookmarked()) { - return "date_tagged"; + return "datetime(date_tagged)"; } else { - return "date_published"; + return "datetime(date_published)"; } } @@ -982,7 +982,7 @@ public static ReaderPostList getPostsInBlog(long blogId, int maxPosts, boolean e String columns = (excludeTextColumn ? COLUMN_NAMES_NO_TEXT : "*"); String sql = "SELECT " + columns + " FROM tbl_posts WHERE blog_id=? AND tag_name='' AND tag_type=0" - + " ORDER BY date_published DESC"; + + " ORDER BY datetime(date_published) DESC"; if (maxPosts > 0) { sql += " LIMIT " + maxPosts; @@ -1020,7 +1020,7 @@ public static ReaderPostList getPostsInFeed(long feedId, int maxPosts, boolean e String columns = (excludeTextColumn ? COLUMN_NAMES_NO_TEXT : "*"); String sql = "SELECT " + columns + " FROM tbl_posts WHERE feed_id=? AND tag_name='' AND tag_type=0" - + " ORDER BY date_published DESC"; + + " ORDER BY datetime(date_published) DESC"; if (maxPosts > 0) { sql += " LIMIT " + maxPosts; @@ -1097,7 +1097,7 @@ private static ReaderBlogIdPostIdList getBlogIdPostIds(@NonNull String sql, @Non */ public static ReaderBlogIdPostIdList getBlogIdPostIdsInBlog(long blogId, int maxPosts) { String sql = "SELECT post_id FROM tbl_posts WHERE blog_id=? AND tag_name='' AND tag_type=0" - + " ORDER BY date_published DESC"; + + " ORDER BY datetime(date_published) DESC"; if (maxPosts > 0) { sql += " LIMIT " + maxPosts; diff --git a/WordPress/src/main/java/org/wordpress/android/designsystem/DesignSystemActivity.kt b/WordPress/src/main/java/org/wordpress/android/designsystem/DesignSystemActivity.kt index 5d15ef196cc9..cdfca9f87357 100644 --- a/WordPress/src/main/java/org/wordpress/android/designsystem/DesignSystemActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/designsystem/DesignSystemActivity.kt @@ -2,6 +2,7 @@ package org.wordpress.android.designsystem import android.content.res.Configuration import android.os.Bundle +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.runtime.Composable import androidx.compose.ui.tooling.preview.Preview import org.wordpress.android.ui.LocaleAwareActivity @@ -10,8 +11,10 @@ import org.wordpress.android.util.extensions.setContent class DesignSystemActivity : LocaleAwareActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContent { - DesignSystem(onBackPressedDispatcher::onBackPressed) + setContent { + DesignSystemTheme(isSystemInDarkTheme()) { + DesignSystem(onBackPressedDispatcher::onBackPressed) + } } } @@ -21,11 +24,10 @@ class DesignSystemActivity : LocaleAwareActivity() { showBackground = true, name = "Dark Mode" ) - @Composable fun PreviewDesignSystemActivity() { - DesignSystem(onBackPressedDispatcher::onBackPressed) + DesignSystemTheme(isSystemInDarkTheme()) { + DesignSystem(onBackPressedDispatcher::onBackPressed) + } } } - - diff --git a/WordPress/src/main/java/org/wordpress/android/designsystem/DesignSystemAppColor.kt b/WordPress/src/main/java/org/wordpress/android/designsystem/DesignSystemAppColor.kt new file mode 100644 index 000000000000..fe3cf85b052c --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/designsystem/DesignSystemAppColor.kt @@ -0,0 +1,80 @@ +package org.wordpress.android.designsystem + +import androidx.compose.runtime.Stable +import androidx.compose.ui.graphics.Color + +/** + * Object containing static common colors used throughout the project. Note that the colors here are not SEMANTIC, + * meaning they don't represent the usage of the color (e.g.: PrimaryButtonBackground) but instead they are raw + * colors used throughout this app's design (e.g.: Green50). + */ +object DesignSystemAppColor { + // Black & White + @Stable + val Black = Color(0xFF000000) + + @Stable + val White = Color(0xFFFFFFFF) + + // Grays + @Stable + val Gray = Color(0xFFF2F2F7) + + @Stable + val Gray10 = Color(0xFFC2C2C6) + + @Stable + val Gray20 = Color(0x99EBEBF5) + + @Stable + val Gray30 = Color(0xFF9B9B9E) + + @Stable + val Gray40 = Color(0x993C3C43) + + @Stable + val Gray50 = Color(0x4D3C3C43) + + @Stable + val Gray60 = Color(0xFF4E4E4F) + + @Stable + val Gray70 = Color(0xFF3A3A3C) + + @Stable + val Gray80 = Color(0xFF2C2C2E) + + // Blues + @Stable + val Blue = Color(0xFF0675C4) + + @Stable + val Blue10 = Color(0xFF399CE3) + + @Stable + val Blue20 = Color(0xFF1689DB) + + // Greens + @Stable + val Green = Color(0xFF008710) + + @Stable + val Green10 = Color(0xFF2FB41F) + + @Stable + val Green20 = Color(0xFF069E08) + + // Reds + @Stable + val Red = Color(0xFFD63638) + + @Stable + val Red10 = Color(0xFFE65054) + + // Oranges + @Stable + val Orange = Color(0xFFD67709) + + @Stable + val Orange10 = Color(0xFFE68B28) +} diff --git a/WordPress/src/main/java/org/wordpress/android/designsystem/DesignSystemScreen.kt b/WordPress/src/main/java/org/wordpress/android/designsystem/DesignSystemScreen.kt index 2168e7048b22..bbe2753a09ac 100644 --- a/WordPress/src/main/java/org/wordpress/android/designsystem/DesignSystemScreen.kt +++ b/WordPress/src/main/java/org/wordpress/android/designsystem/DesignSystemScreen.kt @@ -1,6 +1,8 @@ package org.wordpress.android.designsystem import androidx.annotation.StringRes +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.widthIn @@ -37,7 +39,10 @@ fun SelectOptionButton( Button( onClick = onClick, modifier = modifier.widthIn(min = 250.dp), - colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary) + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.brand, + contentColor = MaterialTheme.colorScheme.primaryContainer + ) ) { Text(stringResource(labelResourceId)) } @@ -46,7 +51,9 @@ fun SelectOptionButton( @Preview @Composable fun StartDesignSystemPreview(){ - DesignSystem {} + DesignSystemTheme(isSystemInDarkTheme()) { + DesignSystem {} + } } @Composable @@ -60,18 +67,19 @@ fun DesignSystem( title = stringResource(R.string.preference_design_system), navigationIcon = NavigationIcons.BackIcon, onNavigationIconClick = { onBackTapped() }, - backgroundColor = MaterialTheme.colorScheme.surface, - contentColor = MaterialTheme.colorScheme.onSurface, + backgroundColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.primary, ) - }, + } ) { innerPadding -> NavHost( + modifier = Modifier.background(MaterialTheme.colorScheme.primaryContainer), navController = navController, startDestination = DesignSystemScreen.Start.name ) { composable(route = DesignSystemScreen.Start.name) { DesignSystemStartScreen( - onNextButtonClicked = { + onButtonClicked = { navController.navigate(it) }, modifier = Modifier diff --git a/WordPress/src/main/java/org/wordpress/android/designsystem/DesignSystemStartScreen.kt b/WordPress/src/main/java/org/wordpress/android/designsystem/DesignSystemStartScreen.kt index e082a6fb5f64..3a4fecb26c92 100644 --- a/WordPress/src/main/java/org/wordpress/android/designsystem/DesignSystemStartScreen.kt +++ b/WordPress/src/main/java/org/wordpress/android/designsystem/DesignSystemStartScreen.kt @@ -13,7 +13,7 @@ import org.wordpress.android.R @Composable fun DesignSystemStartScreen( - onNextButtonClicked: (String) -> Unit, + onButtonClicked: (String) -> Unit, modifier: Modifier = Modifier ) { LazyColumn ( @@ -27,7 +27,7 @@ fun DesignSystemStartScreen( DesignSystemDataSource.startScreenButtonOptions.forEach { item -> SelectOptionButton( labelResourceId = item.first, - onClick = { onNextButtonClicked(item.second) } + onClick = { onButtonClicked(item.second) } ) } } @@ -38,7 +38,7 @@ fun DesignSystemStartScreen( @Composable fun StartDesignSystemStartScreenPreview(){ DesignSystemStartScreen( - onNextButtonClicked = {}, + onButtonClicked = {}, modifier = Modifier .fillMaxSize() .padding(dimensionResource(R.dimen.button_container_shadow_height)) diff --git a/WordPress/src/main/java/org/wordpress/android/designsystem/DesignSystemTheme.kt b/WordPress/src/main/java/org/wordpress/android/designsystem/DesignSystemTheme.kt new file mode 100644 index 000000000000..42e76196ac5e --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/designsystem/DesignSystemTheme.kt @@ -0,0 +1,137 @@ +package org.wordpress.android.designsystem + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.Surface +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color + +private val localColors = staticCompositionLocalOf { extraPaletteLight } + +@Composable +fun DesignSystemTheme( + isDarkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit +) { + DesignSystemThemeWithoutBackground(isDarkTheme) { + ContentInSurface(content) + } +} + +@Composable +fun DesignSystemThemeWithoutBackground( + isDarkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit +) { + val extraColors = if (isDarkTheme) { + extraPaletteDark + } else { + extraPaletteLight + } + + CompositionLocalProvider (localColors provides extraColors) { + MaterialTheme( + colorScheme = if (isDarkTheme) paletteDarkScheme else paletteLightScheme, + content = content + ) + } +} +private val paletteLightScheme = lightColorScheme( + primary = DesignSystemAppColor.Black, + primaryContainer = DesignSystemAppColor.White, + secondary = DesignSystemAppColor.Gray40, + secondaryContainer = DesignSystemAppColor.Gray, + tertiary = DesignSystemAppColor.Gray50, + tertiaryContainer = DesignSystemAppColor.Gray10, + error = DesignSystemAppColor.Red, + ) + +private val paletteDarkScheme = darkColorScheme( + primary = DesignSystemAppColor.White, + primaryContainer = DesignSystemAppColor.Black, + secondary = DesignSystemAppColor.Gray20, + secondaryContainer = DesignSystemAppColor.Gray70, + tertiary = DesignSystemAppColor.Gray10, + tertiaryContainer = DesignSystemAppColor.Gray80, + error = DesignSystemAppColor.Red10, + ) + +private val extraPaletteLight = ExtraColors( + quartenaryContainer = DesignSystemAppColor.Gray30, + brand = DesignSystemAppColor.Green, + brandContainer = DesignSystemAppColor.Green, + warning = DesignSystemAppColor.Orange, + wp = DesignSystemAppColor.Blue, + wpContainer = DesignSystemAppColor.Blue + ) + +private val extraPaletteDark = ExtraColors( + quartenaryContainer = DesignSystemAppColor.Gray60, + brand = DesignSystemAppColor.Green10, + brandContainer = DesignSystemAppColor.Green20, + warning = DesignSystemAppColor.Orange10, + wp = DesignSystemAppColor.Blue10, + wpContainer = DesignSystemAppColor.Blue20 + ) + +data class ExtraColors( + val quartenaryContainer: Color, + val brand: Color, + val brandContainer: Color, + val warning: Color, + val wp: Color, + val wpContainer: Color, + ) +@Suppress("UnusedReceiverParameter") +val ColorScheme.quartenary + @Composable + @ReadOnlyComposable + get() = localColors.current.quartenaryContainer + +@Suppress("UnusedReceiverParameter") +val ColorScheme.brand + @Composable + @ReadOnlyComposable + get() = localColors.current.brand + +@Suppress("UnusedReceiverParameter") +val ColorScheme.brandContainer + @Composable + @ReadOnlyComposable + get() = localColors.current.brandContainer + +@Suppress("UnusedReceiverParameter") +val ColorScheme.warning + @Composable + @ReadOnlyComposable + get() = localColors.current.warning + +@Suppress("UnusedReceiverParameter") +val ColorScheme.wp + @Composable + @ReadOnlyComposable + get() = localColors.current.wp + +@Suppress("UnusedReceiverParameter") +val ColorScheme.wpContainer + @Composable + @ReadOnlyComposable + get() = localColors.current.wpContainer + +@Composable +private fun ContentInSurface( + content: @Composable () -> Unit +) { + Surface(color = MaterialTheme.colorScheme.background) { + ProvideTextStyle(value = MaterialTheme.typography.bodyMedium) { + content() + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/models/ReaderTagList.java b/WordPress/src/main/java/org/wordpress/android/models/ReaderTagList.java index e28444e08bbe..6a9633c44441 100644 --- a/WordPress/src/main/java/org/wordpress/android/models/ReaderTagList.java +++ b/WordPress/src/main/java/org/wordpress/android/models/ReaderTagList.java @@ -67,17 +67,4 @@ public ReaderTagList getDeletions(ReaderTagList otherList) { return deletions; } - - public boolean containsFollowingTag() { - boolean containsFollowing = false; - - for (ReaderTag tag : this) { - if (tag.isFollowedSites() || tag.isDefaultInMemoryTag()) { - containsFollowing = true; - break; - } - } - - return containsFollowing; - } } diff --git a/WordPress/src/main/java/org/wordpress/android/models/ReaderTagListExtension.kt b/WordPress/src/main/java/org/wordpress/android/models/ReaderTagListExtension.kt new file mode 100644 index 000000000000..163b9034abee --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/models/ReaderTagListExtension.kt @@ -0,0 +1,4 @@ +package org.wordpress.android.models + +fun ReaderTagList.containsFollowingTag(): Boolean = + find { it.isFollowedSites || it.isDefaultInMemoryTag } != null diff --git a/WordPress/src/main/java/org/wordpress/android/modules/CodeScannerModule.kt b/WordPress/src/main/java/org/wordpress/android/modules/CodeScannerModule.kt new file mode 100644 index 000000000000..4584657f8a11 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/modules/CodeScannerModule.kt @@ -0,0 +1,38 @@ +package org.wordpress.android.modules + +import com.google.mlkit.vision.barcode.BarcodeScanner +import com.google.mlkit.vision.barcode.BarcodeScanning +import dagger.Module +import dagger.Provides +import dagger.Reusable +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import org.wordpress.android.ui.barcodescanner.CodeScanner +import org.wordpress.android.ui.barcodescanner.GoogleBarcodeFormatMapper +import org.wordpress.android.ui.barcodescanner.GoogleCodeScannerErrorMapper +import org.wordpress.android.ui.barcodescanner.GoogleMLKitCodeScanner +import org.wordpress.android.ui.barcodescanner.MediaImageProvider + +@InstallIn(SingletonComponent::class) +@Module +class CodeScannerModule { + @Provides + @Reusable + fun provideGoogleCodeScanner( + barcodeScanner: BarcodeScanner, + googleCodeScannerErrorMapper: GoogleCodeScannerErrorMapper, + barcodeFormatMapper: GoogleBarcodeFormatMapper, + inputImageProvider: MediaImageProvider, + ): CodeScanner { + return GoogleMLKitCodeScanner( + barcodeScanner, + googleCodeScannerErrorMapper, + barcodeFormatMapper, + inputImageProvider, + ) + } + + @Provides + @Reusable + fun providesGoogleBarcodeScanner() = BarcodeScanning.getClient() +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/ActionableEmptyView.kt b/WordPress/src/main/java/org/wordpress/android/ui/ActionableEmptyView.kt index 879aca347f55..4ca6bd5c37cc 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/ActionableEmptyView.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/ActionableEmptyView.kt @@ -64,51 +64,51 @@ class ActionableEmptyView : LinearLayout { bottomImage = layout.findViewById(R.id.bottom_image) progressBar = layout.findViewById(R.id.actionable_empty_view_progress_bar) - attrs.let { - val typedArray = context.obtainStyledAttributes( - it, - R.styleable.ActionableEmptyView, - 0, - 0 - ) - - val imageResource = typedArray.getResourceId( - R.styleable.ActionableEmptyView_aevImage, - 0 - ) - val hideImageInLandscape = typedArray.getBoolean( - R.styleable.ActionableEmptyView_aevImageHiddenInLandscape, - false - ) - val titleAttribute = typedArray.getString(R.styleable.ActionableEmptyView_aevTitle) - val subtitleAttribute = typedArray.getString(R.styleable.ActionableEmptyView_aevSubtitle) - val buttonAttribute = typedArray.getString(R.styleable.ActionableEmptyView_aevButton) - - if (imageResource != 0) { - image.setImageResource(imageResource) - if (!hideImageInLandscape || !DisplayUtils.isLandscape(context)) { - image.visibility = View.VISIBLE - } + val typedArray = context.obtainStyledAttributes(attrs, R.styleable.ActionableEmptyView, 0, 0) + val imageResource = typedArray.getResourceId( + R.styleable.ActionableEmptyView_aevImage, + 0 + ) + val hideImageInLandscape = typedArray.getBoolean( + R.styleable.ActionableEmptyView_aevImageHiddenInLandscape, + false + ) + val titleAttribute = typedArray.getString(R.styleable.ActionableEmptyView_aevTitle) + val subtitleAttribute = typedArray.getString(R.styleable.ActionableEmptyView_aevSubtitle) + val buttonAttribute = typedArray.getString(R.styleable.ActionableEmptyView_aevButton) + val buttonStyleAttribute = typedArray.getInt( + R.styleable.ActionableEmptyView_aevButtonStyle, + BUTTON_STYLE_PRIMARY + ) + + if (imageResource != 0) { + image.setImageResource(imageResource) + if (!hideImageInLandscape || !DisplayUtils.isLandscape(context)) { + image.visibility = View.VISIBLE } + } - if (!titleAttribute.isNullOrEmpty()) { - title.text = titleAttribute - } else { - throw RuntimeException("$context: ActionableEmptyView must have a title (aevTitle)") - } + if (!titleAttribute.isNullOrEmpty()) { + title.text = titleAttribute + } else { + throw RuntimeException("$context: ActionableEmptyView must have a title (aevTitle)") + } - if (!subtitleAttribute.isNullOrEmpty()) { - subtitle.text = subtitleAttribute - subtitle.visibility = View.VISIBLE - } + if (!subtitleAttribute.isNullOrEmpty()) { + subtitle.text = subtitleAttribute + subtitle.visibility = View.VISIBLE + } - if (!buttonAttribute.isNullOrEmpty()) { - button.text = buttonAttribute - button.visibility = View.VISIBLE - } + if (!buttonAttribute.isNullOrEmpty()) { + button.text = buttonAttribute + button.visibility = View.VISIBLE + } - typedArray.recycle() + if (buttonStyleAttribute == BUTTON_STYLE_READER) { + button.backgroundTintList = context.getColorStateList(R.color.reader_button_primary_background_selector) + button.setTextColor(context.getColorStateList(R.color.reader_button_primary_text)) } + typedArray.recycle() } /** @@ -163,4 +163,9 @@ class ActionableEmptyView : LinearLayout { announceForAccessibility("${title.text}.$subTitle") } + + companion object { + private const val BUTTON_STYLE_PRIMARY = 0 + private const val BUTTON_STYLE_READER = 1 + } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/BarcodeScanner.kt b/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/BarcodeScanner.kt new file mode 100644 index 000000000000..e5df791ee239 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/BarcodeScanner.kt @@ -0,0 +1,99 @@ +package org.wordpress.android.ui.barcodescanner + +import android.content.res.Configuration +import android.util.Size +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST +import androidx.camera.core.ImageProxy +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLifecycleOwner +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import org.wordpress.android.ui.compose.theme.AppTheme +import androidx.camera.core.Preview as CameraPreview + +@Composable +fun BarcodeScanner( + codeScanner: CodeScanner, + onScannedResult: (Flow) -> Unit +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val cameraProviderFuture = remember { + ProcessCameraProvider.getInstance(context) + } + Column( + modifier = Modifier.fillMaxSize() + ) { + AndroidView( + factory = { context -> + val previewView = PreviewView(context) + val preview = CameraPreview.Builder().build() + preview.setSurfaceProvider(previewView.surfaceProvider) + val selector = CameraSelector.Builder().requireLensFacing(CameraSelector.LENS_FACING_BACK).build() + val imageAnalysis = ImageAnalysis.Builder().setTargetResolution( + Size( + previewView.width, + previewView.height + ) + ) + .setBackpressureStrategy(STRATEGY_KEEP_ONLY_LATEST) + .build() + imageAnalysis.setAnalyzer(ContextCompat.getMainExecutor(context)) { imageProxy -> + onScannedResult(codeScanner.startScan(imageProxy)) + } + try { + cameraProviderFuture.get().bindToLifecycle(lifecycleOwner, selector, preview, imageAnalysis) + } catch (e: IllegalStateException) { + onScannedResult( + flowOf( + CodeScannerStatus.Failure( + e.message + ?: "Illegal state exception while binding camera provider to lifecycle", + CodeScanningErrorType.Other(e) + ) + ) + ) + } catch (e: IllegalArgumentException) { + onScannedResult( + flowOf( + CodeScannerStatus.Failure( + e.message + ?: "Illegal argument exception while binding camera provider to lifecycle", + CodeScanningErrorType.Other(e) + ) + ) + ) + } + previewView + }, + modifier = Modifier.fillMaxSize() + ) + } +} + +class DummyCodeScanner : CodeScanner { + override fun startScan(imageProxy: ImageProxy): Flow { + return flowOf(CodeScannerStatus.Success("", GoogleBarcodeFormatMapper.BarcodeFormat.FormatUPCA)) + } +} + +@Preview(name = "Light mode") +@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun BarcodeScannerScreenPreview() { + AppTheme { + BarcodeScanner(codeScanner = DummyCodeScanner(), onScannedResult = {}) + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/BarcodeScannerScreen.kt b/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/BarcodeScannerScreen.kt new file mode 100644 index 000000000000..01a270db9ece --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/BarcodeScannerScreen.kt @@ -0,0 +1,146 @@ +package org.wordpress.android.ui.barcodescanner + +import android.content.res.Configuration +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.layout.padding +import androidx.compose.material.AlertDialog +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.wordpress.android.R +import kotlinx.coroutines.flow.Flow +import org.wordpress.android.ui.compose.theme.AppTheme + +@Composable +fun BarcodeScannerScreen( + codeScanner: CodeScanner, + permissionState: BarcodeScanningViewModel.PermissionState, + onResult: (Boolean) -> Unit, + onScannedResult: (Flow) -> Unit, +) { + val cameraPermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + onResult = { granted -> + onResult(granted) + }, + ) + LaunchedEffect(key1 = Unit) { + cameraPermissionLauncher.launch(BarcodeScanningFragment.KEY_CAMERA_PERMISSION) + } + when (permissionState) { + BarcodeScanningViewModel.PermissionState.Granted -> { + BarcodeScanner( + codeScanner = codeScanner, + onScannedResult = onScannedResult + ) + } + is BarcodeScanningViewModel.PermissionState.ShouldShowRationale -> { + AlertDialog( + title = stringResource(id = permissionState.title), + message = stringResource(id = permissionState.message), + ctaLabel = stringResource(id = permissionState.ctaLabel), + dismissCtaLabel = stringResource(id = permissionState.dismissCtaLabel), + ctaAction = { permissionState.ctaAction.invoke(cameraPermissionLauncher) }, + dismissCtaAction = { permissionState.dismissCtaAction.invoke() } + ) + } + is BarcodeScanningViewModel.PermissionState.PermanentlyDenied -> { + AlertDialog( + title = stringResource(id = permissionState.title), + message = stringResource(id = permissionState.message), + ctaLabel = stringResource(id = permissionState.ctaLabel), + dismissCtaLabel = stringResource(id = permissionState.dismissCtaLabel), + ctaAction = { permissionState.ctaAction.invoke(cameraPermissionLauncher) }, + dismissCtaAction = { permissionState.dismissCtaAction.invoke() } + ) + } + BarcodeScanningViewModel.PermissionState.Unknown -> { + // no-op + } + } +} + +@Composable +private fun AlertDialog( + title: String, + message: String, + ctaLabel: String, + dismissCtaLabel: String, + ctaAction: () -> Unit, + dismissCtaAction: () -> Unit, +) { + AlertDialog( + onDismissRequest = { dismissCtaAction() }, + title = { + Text(title) + }, + text = { + Text(message) + }, + confirmButton = { + TextButton( + onClick = { + ctaAction() + } + ) { + Text( + ctaLabel, + color = MaterialTheme.colors.secondary, + modifier = Modifier.padding(8.dp) + ) + } + }, + dismissButton = { + TextButton( + onClick = { + dismissCtaAction() + } + ) { + Text( + dismissCtaLabel, + color = MaterialTheme.colors.secondary, + modifier = Modifier.padding(8.dp) + ) + } + }, + ) +} + +@Preview(name = "Light mode") +@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun DeniedOnceAlertDialog() { + AppTheme { + AlertDialog( + title = stringResource(id = R.string.barcode_scanning_alert_dialog_title), + message = stringResource(id = R.string.barcode_scanning_alert_dialog_rationale_message), + ctaLabel = stringResource(id = R.string.barcode_scanning_alert_dialog_rationale_cta_label), + dismissCtaLabel = stringResource(id = R.string.barcode_scanning_alert_dialog_dismiss_label), + ctaAction = {}, + dismissCtaAction = {}, + ) + } +} + +@Preview(name = "Light mode") +@Preview(name = "Dark mode", uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun DeniedPermanentlyAlertDialog() { + AppTheme { + AlertDialog( + title = stringResource(id = R.string.barcode_scanning_alert_dialog_title), + message = stringResource(id = R.string.barcode_scanning_alert_dialog_permanently_denied_message), + ctaLabel = stringResource(id = R.string.barcode_scanning_alert_dialog_permanently_denied_cta_label), + dismissCtaLabel = stringResource(id = R.string.barcode_scanning_alert_dialog_dismiss_label), + ctaAction = {}, + dismissCtaAction = {}, + ) + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/BarcodeScanningFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/BarcodeScanningFragment.kt new file mode 100644 index 000000000000..a05fd7c67769 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/BarcodeScanningFragment.kt @@ -0,0 +1,102 @@ +package org.wordpress.android.ui.barcodescanner + +import android.Manifest +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.activity.addCallback +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import androidx.fragment.app.setFragmentResult +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import org.wordpress.android.ui.compose.theme.AppTheme +import org.wordpress.android.util.WPPermissionUtils +import javax.inject.Inject + +@AndroidEntryPoint +class BarcodeScanningFragment : Fragment() { + private val viewModel: BarcodeScanningViewModel by viewModels() + + @Inject + lateinit var codeScanner: GoogleMLKitCodeScanner + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?) = + ComposeView(requireContext()) + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + view as ComposeView + view.setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + observeCameraPermissionState(view) + observeViewModelEvents() + initBackPressHandler() + } + + private fun observeCameraPermissionState(view: ComposeView) { + viewModel.permissionState.observe(viewLifecycleOwner) { permissionState -> + view.setContent { + AppTheme { + BarcodeScannerScreen( + codeScanner = codeScanner, + permissionState = permissionState, + onResult = { granted -> + viewModel.updatePermissionState( + granted, + shouldShowRequestPermissionRationale(KEY_CAMERA_PERMISSION) + ) + }, + onScannedResult = { codeScannerStatus -> + viewLifecycleOwner.lifecycleScope.launch { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.CREATED) { + codeScannerStatus.collect { status -> + setResultAndPopStack(status) + } + } + } + }, + ) + } + } + } + } + private fun observeViewModelEvents() { + viewModel.event.observe(viewLifecycleOwner) { event -> + when (event) { + is BarcodeScanningViewModel.ScanningEvents.LaunchCameraPermission -> { + event.cameraLauncher.launch(KEY_CAMERA_PERMISSION) + } + + is BarcodeScanningViewModel.ScanningEvents.OpenAppSettings -> { + WPPermissionUtils.showAppSettings(requireContext()) + } + + is BarcodeScanningViewModel.ScanningEvents.Exit -> { + setResultAndPopStack(CodeScannerStatus.Exit) + } + } + } + } + + private fun initBackPressHandler() { + requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner) { + setResultAndPopStack(CodeScannerStatus.NavigateUp) } + } + + private fun setResultAndPopStack(status: CodeScannerStatus) { + setFragmentResult(KEY_BARCODE_SCANNING_REQUEST, bundleOf(KEY_BARCODE_SCANNING_SCAN_STATUS to status)) + requireActivity().supportFragmentManager.popBackStackImmediate() + } + + companion object { + const val KEY_BARCODE_SCANNING_SCAN_STATUS = "barcode_scanning_scan_status" + const val KEY_BARCODE_SCANNING_REQUEST = "key_barcode_scanning_request" + const val KEY_CAMERA_PERMISSION = Manifest.permission.CAMERA + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/BarcodeScanningTracker.kt b/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/BarcodeScanningTracker.kt new file mode 100644 index 000000000000..c6455ddeed40 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/BarcodeScanningTracker.kt @@ -0,0 +1,37 @@ +package org.wordpress.android.ui.barcodescanner + +import org.wordpress.android.analytics.AnalyticsTracker +import org.wordpress.android.util.analytics.AnalyticsTrackerWrapper +import javax.inject.Inject + +class BarcodeScanningTracker @Inject constructor( + private val analyticsTrackerWrapper: AnalyticsTrackerWrapper +) { + fun trackScanFailure(source: ScanningSource, type: CodeScanningErrorType) { + analyticsTrackerWrapper.track( + AnalyticsTracker.Stat.BARCODE_SCANNING_FAILURE, + mapOf( + KEY_SCANNING_SOURCE to source.source, + KEY_SCANNING_FAILURE_REASON to type.toString(), + ) + ) + } + + fun trackSuccess(source: ScanningSource) { + analyticsTrackerWrapper.track( + AnalyticsTracker.Stat.BARCODE_SCANNING_SUCCESS, + mapOf( + KEY_SCANNING_SOURCE to source.source + ) + ) + } + + companion object { + const val KEY_SCANNING_SOURCE = "source" + const val KEY_SCANNING_FAILURE_REASON = "scanning_failure_reason" + } +} + +enum class ScanningSource(val source: String) { + QRCODE_LOGIN("qrcode_login") +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/BarcodeScanningViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/BarcodeScanningViewModel.kt new file mode 100644 index 000000000000..532bc34f3ccd --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/BarcodeScanningViewModel.kt @@ -0,0 +1,107 @@ +package org.wordpress.android.ui.barcodescanner + +import androidx.activity.compose.ManagedActivityResultLauncher +import androidx.annotation.StringRes +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher +import org.wordpress.android.R +import org.wordpress.android.modules.BG_THREAD +import org.wordpress.android.viewmodel.ScopedViewModel +import javax.inject.Inject +import javax.inject.Named + +@HiltViewModel +class BarcodeScanningViewModel @Inject constructor( + @param:Named(BG_THREAD) private val bgDispatcher: CoroutineDispatcher +) : ScopedViewModel(bgDispatcher) { + private val _permissionState = MutableLiveData() + val permissionState: LiveData = _permissionState + + private val _event: MutableLiveData = MutableLiveData() + val event: LiveData = _event + + init { + _permissionState.value = PermissionState.Unknown + } + + fun updatePermissionState( + isPermissionGranted: Boolean, + shouldShowRequestPermissionRationale: Boolean, + ) { + when { + isPermissionGranted -> { + // display scanning screen + _permissionState.value = PermissionState.Granted + } + + // It will launch events that some place can response to + shouldShowRequestPermissionRationale -> { + // Denied once, ask to grant camera permission + _permissionState.value = PermissionState.ShouldShowRationale( + title = R.string.barcode_scanning_alert_dialog_title, + message = R.string.barcode_scanning_alert_dialog_rationale_message, + ctaLabel = R.string.barcode_scanning_alert_dialog_rationale_cta_label, + dismissCtaLabel = R.string.barcode_scanning_alert_dialog_dismiss_label, + ctaAction = { _event.value = ScanningEvents.LaunchCameraPermission(it) }, + dismissCtaAction = { + _event.value = (ScanningEvents.Exit) + } + ) + } + + else -> { + // Permanently denied, ask to enable permission from the app settings + _permissionState.value = PermissionState.PermanentlyDenied( + title = R.string.barcode_scanning_alert_dialog_title, + message = R.string.barcode_scanning_alert_dialog_permanently_denied_message, + ctaLabel = R.string.barcode_scanning_alert_dialog_permanently_denied_cta_label, + dismissCtaLabel = R.string.barcode_scanning_alert_dialog_dismiss_label, + ctaAction = { + _event.value = ScanningEvents.OpenAppSettings(it) + }, + dismissCtaAction = { + _event.value = (ScanningEvents.Exit) + } + ) + } + } + } + + sealed class ScanningEvents { + data class LaunchCameraPermission( + val cameraLauncher: ManagedActivityResultLauncher + ) : ScanningEvents() + + data class OpenAppSettings( + val cameraLauncher: ManagedActivityResultLauncher + ) : ScanningEvents() + + object Exit : ScanningEvents() + } + + sealed class PermissionState { + object Granted : PermissionState() + + data class ShouldShowRationale( + @StringRes val title: Int, + @StringRes val message: Int, + @StringRes val ctaLabel: Int, + @StringRes val dismissCtaLabel: Int, + val ctaAction: (ManagedActivityResultLauncher) -> Unit, + val dismissCtaAction: () -> Unit, + ) : PermissionState() + + data class PermanentlyDenied( + @StringRes val title: Int, + @StringRes val message: Int, + @StringRes val ctaLabel: Int, + @StringRes val dismissCtaLabel: Int, + val ctaAction: (ManagedActivityResultLauncher) -> Unit, + val dismissCtaAction: () -> Unit, + ) : PermissionState() + + object Unknown : PermissionState() + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/CodeScanner.kt b/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/CodeScanner.kt new file mode 100644 index 000000000000..6dde760e0bf0 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/CodeScanner.kt @@ -0,0 +1,92 @@ +package org.wordpress.android.ui.barcodescanner + +import android.os.Parcelable +import androidx.camera.core.ImageProxy +import kotlinx.coroutines.flow.Flow +import kotlinx.parcelize.Parcelize + +interface CodeScanner { + fun startScan(imageProxy: ImageProxy): Flow +} + +sealed class CodeScannerStatus : Parcelable { + @Parcelize + data class Success(val code: String, val format: GoogleBarcodeFormatMapper.BarcodeFormat) : CodeScannerStatus() + @Parcelize + data class Failure( + val error: String?, + val type: CodeScanningErrorType + ) : CodeScannerStatus() + @Parcelize + data object NavigateUp : CodeScannerStatus() + @Parcelize + data object Exit : CodeScannerStatus() +} + +sealed class CodeScanningErrorType : Parcelable { + @Parcelize + object Aborted : CodeScanningErrorType() + @Parcelize + object AlreadyExists : CodeScanningErrorType() + @Parcelize + object Cancelled : CodeScanningErrorType() + @Parcelize + object CodeScannerAppNameUnavailable : CodeScanningErrorType() + @Parcelize + object CodeScannerCameraPermissionNotGranted : CodeScanningErrorType() + @Parcelize + object CodeScannerCancelled : CodeScanningErrorType() + @Parcelize + object CodeScannerGooglePlayServicesVersionTooOld : CodeScanningErrorType() + @Parcelize + object CodeScannerPipelineInferenceError : CodeScanningErrorType() + @Parcelize + object CodeScannerPipelineInitializationError : CodeScanningErrorType() + @Parcelize + object CodeScannerTaskInProgress : CodeScanningErrorType() + @Parcelize + object CodeScannerUnavailable : CodeScanningErrorType() + @Parcelize + object DataLoss : CodeScanningErrorType() + @Parcelize + object DeadlineExceeded : CodeScanningErrorType() + @Parcelize + object FailedPrecondition : CodeScanningErrorType() + @Parcelize + object Internal : CodeScanningErrorType() + @Parcelize + object InvalidArgument : CodeScanningErrorType() + @Parcelize + object ModelHashMismatch : CodeScanningErrorType() + @Parcelize + object ModelIncompatibleWithTFLite : CodeScanningErrorType() + @Parcelize + object NetworkIssue : CodeScanningErrorType() + @Parcelize + object NotEnoughSpace : CodeScanningErrorType() + @Parcelize + object NotFound : CodeScanningErrorType() + @Parcelize + object OutOfRange : CodeScanningErrorType() + @Parcelize + object PermissionDenied : CodeScanningErrorType() + @Parcelize + object ResourceExhausted : CodeScanningErrorType() + @Parcelize + object UnAuthenticated : CodeScanningErrorType() + @Parcelize + object UnAvailable : CodeScanningErrorType() + @Parcelize + object UnImplemented : CodeScanningErrorType() + @Parcelize + object Unknown : CodeScanningErrorType() + @Parcelize + data class Other(val throwable: Throwable?) : CodeScanningErrorType() + + override fun toString(): String = when (this) { + is Other -> this.throwable?.message ?: "Other" + else -> this.javaClass.run { + name.removePrefix("${`package`?.name ?: ""}.") + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/GoogleBarcodeFormatMapper.kt b/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/GoogleBarcodeFormatMapper.kt new file mode 100644 index 000000000000..ef7f9973f399 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/GoogleBarcodeFormatMapper.kt @@ -0,0 +1,60 @@ +package org.wordpress.android.ui.barcodescanner + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import javax.inject.Inject +import com.google.mlkit.vision.barcode.common.Barcode as GoogleBarcode + +class GoogleBarcodeFormatMapper @Inject constructor() { + @Suppress("ComplexMethod") + fun mapBarcodeFormat(format: Int): BarcodeFormat { + return when (format) { + GoogleBarcode.FORMAT_AZTEC -> BarcodeFormat.FormatAztec + GoogleBarcode.FORMAT_CODABAR -> BarcodeFormat.FormatCodaBar + GoogleBarcode.FORMAT_CODE_128 -> BarcodeFormat.FormatCode128 + GoogleBarcode.FORMAT_CODE_39 -> BarcodeFormat.FormatCode39 + GoogleBarcode.FORMAT_CODE_93 -> BarcodeFormat.FormatCode93 + GoogleBarcode.FORMAT_DATA_MATRIX -> BarcodeFormat.FormatDataMatrix + GoogleBarcode.FORMAT_EAN_13 -> BarcodeFormat.FormatEAN13 + GoogleBarcode.FORMAT_EAN_8 -> BarcodeFormat.FormatEAN8 + GoogleBarcode.FORMAT_ITF -> BarcodeFormat.FormatITF + GoogleBarcode.FORMAT_PDF417 -> BarcodeFormat.FormatPDF417 + GoogleBarcode.FORMAT_QR_CODE -> BarcodeFormat.FormatQRCode + GoogleBarcode.FORMAT_UPC_A -> BarcodeFormat.FormatUPCA + GoogleBarcode.FORMAT_UPC_E -> BarcodeFormat.FormatUPCE + GoogleBarcode.FORMAT_UNKNOWN -> BarcodeFormat.FormatUnknown + else -> BarcodeFormat.FormatUnknown + } + } + + sealed class BarcodeFormat(val formatName: String) : Parcelable { + @Parcelize + object FormatAztec : BarcodeFormat("aztec") + @Parcelize + object FormatCodaBar : BarcodeFormat("codabar") + @Parcelize + object FormatCode128 : BarcodeFormat("code_128") + @Parcelize + object FormatCode39 : BarcodeFormat("code_39") + @Parcelize + object FormatCode93 : BarcodeFormat("code_93") + @Parcelize + object FormatDataMatrix : BarcodeFormat("data_matrix") + @Parcelize + object FormatEAN13 : BarcodeFormat("ean_13") + @Parcelize + object FormatEAN8 : BarcodeFormat("ean_8") + @Parcelize + object FormatITF : BarcodeFormat("itf") + @Parcelize + object FormatPDF417 : BarcodeFormat("pdf_417") + @Parcelize + object FormatQRCode : BarcodeFormat("qr_code") + @Parcelize + object FormatUPCA : BarcodeFormat("upc_a") + @Parcelize + object FormatUPCE : BarcodeFormat("upc_e") + @Parcelize + object FormatUnknown : BarcodeFormat("unknown") + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/GoogleCodeScannerErrorMapper.kt b/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/GoogleCodeScannerErrorMapper.kt new file mode 100644 index 000000000000..b8c8721f10b5 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/GoogleCodeScannerErrorMapper.kt @@ -0,0 +1,74 @@ +package org.wordpress.android.ui.barcodescanner + +import com.google.mlkit.common.MlKitException +import com.google.mlkit.common.MlKitException.ABORTED +import com.google.mlkit.common.MlKitException.ALREADY_EXISTS +import com.google.mlkit.common.MlKitException.CANCELLED +import com.google.mlkit.common.MlKitException.CODE_SCANNER_APP_NAME_UNAVAILABLE +import com.google.mlkit.common.MlKitException.CODE_SCANNER_CAMERA_PERMISSION_NOT_GRANTED +import com.google.mlkit.common.MlKitException.CODE_SCANNER_CANCELLED +import com.google.mlkit.common.MlKitException.CODE_SCANNER_GOOGLE_PLAY_SERVICES_VERSION_TOO_OLD +import com.google.mlkit.common.MlKitException.CODE_SCANNER_PIPELINE_INFERENCE_ERROR +import com.google.mlkit.common.MlKitException.CODE_SCANNER_PIPELINE_INITIALIZATION_ERROR +import com.google.mlkit.common.MlKitException.CODE_SCANNER_TASK_IN_PROGRESS +import com.google.mlkit.common.MlKitException.CODE_SCANNER_UNAVAILABLE +import com.google.mlkit.common.MlKitException.DATA_LOSS +import com.google.mlkit.common.MlKitException.DEADLINE_EXCEEDED +import com.google.mlkit.common.MlKitException.FAILED_PRECONDITION +import com.google.mlkit.common.MlKitException.INTERNAL +import com.google.mlkit.common.MlKitException.INVALID_ARGUMENT +import com.google.mlkit.common.MlKitException.MODEL_HASH_MISMATCH +import com.google.mlkit.common.MlKitException.MODEL_INCOMPATIBLE_WITH_TFLITE +import com.google.mlkit.common.MlKitException.NETWORK_ISSUE +import com.google.mlkit.common.MlKitException.NOT_ENOUGH_SPACE +import com.google.mlkit.common.MlKitException.NOT_FOUND +import com.google.mlkit.common.MlKitException.OUT_OF_RANGE +import com.google.mlkit.common.MlKitException.PERMISSION_DENIED +import com.google.mlkit.common.MlKitException.RESOURCE_EXHAUSTED +import com.google.mlkit.common.MlKitException.UNAUTHENTICATED +import com.google.mlkit.common.MlKitException.UNAVAILABLE +import com.google.mlkit.common.MlKitException.UNIMPLEMENTED +import com.google.mlkit.common.MlKitException.UNKNOWN +import javax.inject.Inject + +class GoogleCodeScannerErrorMapper @Inject constructor() { + @Suppress("ComplexMethod") + fun mapGoogleMLKitScanningErrors( + exception: Throwable? + ): CodeScanningErrorType { + return when ((exception as? MlKitException)?.errorCode) { + ABORTED -> CodeScanningErrorType.Aborted + ALREADY_EXISTS -> CodeScanningErrorType.AlreadyExists + CANCELLED -> CodeScanningErrorType.Cancelled + CODE_SCANNER_APP_NAME_UNAVAILABLE -> CodeScanningErrorType.CodeScannerAppNameUnavailable + CODE_SCANNER_CAMERA_PERMISSION_NOT_GRANTED -> + CodeScanningErrorType.CodeScannerCameraPermissionNotGranted + CODE_SCANNER_CANCELLED -> CodeScanningErrorType.CodeScannerCancelled + CODE_SCANNER_GOOGLE_PLAY_SERVICES_VERSION_TOO_OLD -> + CodeScanningErrorType.CodeScannerGooglePlayServicesVersionTooOld + CODE_SCANNER_PIPELINE_INFERENCE_ERROR -> CodeScanningErrorType.CodeScannerPipelineInferenceError + CODE_SCANNER_PIPELINE_INITIALIZATION_ERROR -> + CodeScanningErrorType.CodeScannerPipelineInitializationError + CODE_SCANNER_TASK_IN_PROGRESS -> CodeScanningErrorType.CodeScannerTaskInProgress + CODE_SCANNER_UNAVAILABLE -> CodeScanningErrorType.CodeScannerUnavailable + DATA_LOSS -> CodeScanningErrorType.DataLoss + DEADLINE_EXCEEDED -> CodeScanningErrorType.DeadlineExceeded + FAILED_PRECONDITION -> CodeScanningErrorType.FailedPrecondition + INTERNAL -> CodeScanningErrorType.Internal + INVALID_ARGUMENT -> CodeScanningErrorType.InvalidArgument + MODEL_HASH_MISMATCH -> CodeScanningErrorType.ModelHashMismatch + MODEL_INCOMPATIBLE_WITH_TFLITE -> CodeScanningErrorType.ModelIncompatibleWithTFLite + NETWORK_ISSUE -> CodeScanningErrorType.NetworkIssue + NOT_ENOUGH_SPACE -> CodeScanningErrorType.NotEnoughSpace + NOT_FOUND -> CodeScanningErrorType.NotFound + OUT_OF_RANGE -> CodeScanningErrorType.OutOfRange + PERMISSION_DENIED -> CodeScanningErrorType.PermissionDenied + RESOURCE_EXHAUSTED -> CodeScanningErrorType.ResourceExhausted + UNAUTHENTICATED -> CodeScanningErrorType.UnAuthenticated + UNAVAILABLE -> CodeScanningErrorType.UnAvailable + UNIMPLEMENTED -> CodeScanningErrorType.UnImplemented + UNKNOWN -> CodeScanningErrorType.Unknown + else -> CodeScanningErrorType.Other(exception) + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/GoogleMLKitCodeScanner.kt b/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/GoogleMLKitCodeScanner.kt new file mode 100644 index 000000000000..2e0d339c473e --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/GoogleMLKitCodeScanner.kt @@ -0,0 +1,70 @@ +package org.wordpress.android.ui.barcodescanner + +import androidx.camera.core.ImageProxy +import com.google.mlkit.vision.barcode.BarcodeScanner +import com.google.mlkit.vision.barcode.common.Barcode +import kotlinx.coroutines.channels.ProducerScope +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import javax.inject.Inject + +class GoogleMLKitCodeScanner @Inject constructor( + private val barcodeScanner: BarcodeScanner, + private val errorMapper: GoogleCodeScannerErrorMapper, + private val barcodeFormatMapper: GoogleBarcodeFormatMapper, + private val inputImageProvider: MediaImageProvider, +) : CodeScanner { + private var barcodeFound = false + @androidx.camera.core.ExperimentalGetImage + override fun startScan(imageProxy: ImageProxy): Flow { + return callbackFlow { + val barcodeTask = barcodeScanner.process(inputImageProvider.provideImage(imageProxy)) + barcodeTask.addOnCompleteListener { + // We must call image.close() on received images when finished using them. + // Otherwise, new images may not be received or the camera may stall. + imageProxy.close() + } + barcodeTask.addOnSuccessListener { barcodeList -> + // The check for barcodeFound is done because the startScan method will be called + // continuously by the library as long as we are in the scanning screen. + // There will be a good chance that the same barcode gets identified multiple times and as a result + // success callback will be called multiple times. + if (!barcodeList.isNullOrEmpty() && !barcodeFound) { + barcodeFound = true + handleScanSuccess(barcodeList.firstOrNull()) + this@callbackFlow.close() + } + } + barcodeTask.addOnFailureListener { exception -> + this@callbackFlow.trySend( + CodeScannerStatus.Failure( + error = exception.message, + type = errorMapper.mapGoogleMLKitScanningErrors(exception) + ) + ) + this@callbackFlow.close() + } + + awaitClose() + } + } + + private fun ProducerScope.handleScanSuccess(code: Barcode?) { + code?.rawValue?.let { + trySend( + CodeScannerStatus.Success( + it, + barcodeFormatMapper.mapBarcodeFormat(code.format) + ) + ) + } ?: run { + trySend( + CodeScannerStatus.Failure( + error = "Failed to find a valid raw value!", + type = CodeScanningErrorType.Other(Throwable("Empty raw value")) + ) + ) + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/InputImageProvider.kt b/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/InputImageProvider.kt new file mode 100644 index 000000000000..2184e4bcbe7e --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/barcodescanner/InputImageProvider.kt @@ -0,0 +1,15 @@ +package org.wordpress.android.ui.barcodescanner + +import androidx.camera.core.ImageProxy +import com.google.mlkit.vision.common.InputImage +import javax.inject.Inject + +interface InputImageProvider { + fun provideImage(imageProxy: ImageProxy): InputImage +} +class MediaImageProvider @Inject constructor() : InputImageProvider { + @androidx.camera.core.ExperimentalGetImage + override fun provideImage(imageProxy: ImageProxy): InputImage { + return InputImage.fromMediaImage(imageProxy.image!!, imageProxy.imageInfo.rotationDegrees) + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/blaze/blazecampaigns/campaignlisting/CampaignListingFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/blaze/blazecampaigns/campaignlisting/CampaignListingFragment.kt index 504bbcbb3762..5996193243ce 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/blaze/blazecampaigns/campaignlisting/CampaignListingFragment.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/blaze/blazecampaigns/campaignlisting/CampaignListingFragment.kt @@ -55,6 +55,7 @@ import org.wordpress.android.ui.compose.components.MainTopAppBar import org.wordpress.android.ui.compose.components.NavigationIcons import org.wordpress.android.ui.compose.theme.AppColor import org.wordpress.android.ui.compose.theme.AppTheme +import org.wordpress.android.ui.compose.utils.isLightTheme import org.wordpress.android.ui.compose.utils.uiStringText import org.wordpress.android.ui.main.jetpack.migration.compose.state.LoadingState import org.wordpress.android.ui.utils.UiString @@ -118,6 +119,9 @@ class CampaignListingFragment : Fragment() { } } } + viewModel.onSelectedSiteMissing.observe(viewLifecycleOwner) { + requireActivity().finish() + } } private fun getPageSource(): CampaignListingPageSource { @@ -276,7 +280,7 @@ fun CampaignListingErrorPreview() { @Composable private fun CreateCampaignFloatingActionButton(modifier: Modifier = Modifier, onClick: () -> Unit) { - val isInDarkMode = !MaterialTheme.colors.isLight + val isInDarkMode = !isLightTheme() FloatingActionButton( modifier = modifier, onClick = onClick, diff --git a/WordPress/src/main/java/org/wordpress/android/ui/blaze/blazecampaigns/campaignlisting/CampaignListingViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/blaze/blazecampaigns/campaignlisting/CampaignListingViewModel.kt index c3fc182097ba..182a29884a17 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/blaze/blazecampaigns/campaignlisting/CampaignListingViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/blaze/blazecampaigns/campaignlisting/CampaignListingViewModel.kt @@ -7,8 +7,8 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.asSharedFlow -import org.wordpress.android.Result import org.wordpress.android.R +import org.wordpress.android.Result import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.modules.BG_THREAD import org.wordpress.android.ui.blaze.BlazeFeatureUtils @@ -47,12 +47,20 @@ class CampaignListingViewModel @Inject constructor( private val _snackbar = MutableSharedFlow() val snackBar = _snackbar.asSharedFlow() + private val _onSelectedSiteMissing = MutableLiveData() + val onSelectedSiteMissing = _onSelectedSiteMissing as LiveData + private var page = 1 private var limitPerPage: Int = 10 private var isLastPage: Boolean = false fun start(campaignListingPageSource: CampaignListingPageSource) { - this.site = selectedSiteRepository.getSelectedSite()!! + val site = selectedSiteRepository.getSelectedSite() + if (site == null) { + _onSelectedSiteMissing.value = Unit + return + } + this.site = site blazeFeatureUtils.trackCampaignListingPageShown(campaignListingPageSource) loadCampaigns() } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/compose/components/buttons/WPSwitch.kt b/WordPress/src/main/java/org/wordpress/android/ui/compose/components/buttons/WPSwitch.kt index b0bbe6c33799..57a6f39477c9 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/compose/components/buttons/WPSwitch.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/compose/components/buttons/WPSwitch.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import org.wordpress.android.ui.compose.theme.AppTheme +import org.wordpress.android.ui.compose.utils.isLightTheme import org.wordpress.android.widgets.WPSwitchCompat import com.google.android.material.R as MaterialR @@ -79,14 +80,14 @@ object WPSwitchDefaults { fun colors(): SwitchColors { // thumb colors val thumbDisabledColor = colorResource( - if (MaterialTheme.colors.isLight) { + if (isLightTheme()) { MaterialR.color.switch_thumb_disabled_material_light } else { MaterialR.color.switch_thumb_disabled_material_dark } ) val thumbEnabledUncheckedColor = colorResource( - if (MaterialTheme.colors.isLight) { + if (isLightTheme()) { MaterialR.color.switch_thumb_normal_material_light } else { MaterialR.color.switch_thumb_normal_material_dark diff --git a/WordPress/src/main/java/org/wordpress/android/ui/compose/components/menu/dropdown/DropdownMenuButton.kt b/WordPress/src/main/java/org/wordpress/android/ui/compose/components/menu/dropdown/DropdownMenuButton.kt new file mode 100644 index 000000000000..811451e3feda --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/compose/components/menu/dropdown/DropdownMenuButton.kt @@ -0,0 +1,137 @@ +package org.wordpress.android.ui.compose.components.menu.dropdown + +import android.content.res.Configuration +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.spring +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Button +import androidx.compose.material.ButtonDefaults +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.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.IntSize +import androidx.compose.ui.unit.dp +import org.wordpress.android.R +import org.wordpress.android.ui.compose.components.menu.dropdown.MenuElementData.Item +import org.wordpress.android.ui.compose.theme.AppTheme +import org.wordpress.android.ui.compose.unit.Margin +import org.wordpress.android.ui.compose.utils.uiStringText +import org.wordpress.android.ui.utils.UiString.UiStringText +import androidx.compose.material3.MaterialTheme as Material3Theme + +@Composable +fun DropdownMenuButton( + selectedItem: Item, + onClick: () -> Unit, + height: Dp = 36.dp, + contentSizeAnimation: FiniteAnimationSpec = spring(), +) { + Button( + onClick = onClick, + modifier = Modifier.height(height), + elevation = ButtonDefaults.elevation( + defaultElevation = 0.dp, + pressedElevation = 0.dp, + ), + colors = ButtonDefaults.buttonColors( + contentColor = MaterialTheme.colors.onPrimary, + backgroundColor = MaterialTheme.colors.onSurface, + ), + shape = RoundedCornerShape(50), + contentPadding = PaddingValues( + start = Margin.MediumLarge.value, + end = Margin.MediumLarge.value, + top = 0.dp, + bottom = 0.dp + ) + ) { + Row( + modifier = Modifier.animateContentSize(contentSizeAnimation), + ) { + if (selectedItem is Item.Single && selectedItem.leadingIcon != NO_ICON) { + Icon( + modifier = Modifier.align(Alignment.CenterVertically), + painter = painterResource(id = selectedItem.leadingIcon), + contentDescription = null, + ) + Spacer(Modifier.width(Margin.Small.value)) + } + Text( + modifier = Modifier + .align(Alignment.CenterVertically) + .widthIn(max = 280.dp), + style = Material3Theme.typography.titleMedium, + fontWeight = FontWeight.Medium, + text = uiStringText(selectedItem.text), + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + Spacer(Modifier.width(Margin.Small.value)) + Icon( + modifier = Modifier.align(Alignment.CenterVertically), + painter = painterResource(id = R.drawable.ic_small_chevron_down_white_16dp), + contentDescription = null, + tint = MaterialTheme.colors.onPrimary, + ) + } + } +} + +@Preview +@Preview(showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +private fun JetpackDropdownMenuButtonPreview() { + AppTheme { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + DropdownMenuButton( + selectedItem = Item.Single( + id = "text-only", + text = UiStringText("Text only"), + ), + onClick = {} + ) + DropdownMenuButton( + selectedItem = Item.Single( + id = "text-and-icon", + text = UiStringText("Text and Icon"), + leadingIcon = R.drawable.ic_jetpack_logo_white_24dp, + ), + onClick = {}, + ) + DropdownMenuButton( + selectedItem = Item.Single( + id = "text-with-a-really-long-text-as-the-button-label", + text = UiStringText("Text type with a really long text as the button label"), + ), + onClick = {}, + ) + DropdownMenuButton( + selectedItem = Item.Single( + id = "text-with-a-really-long-text-as-the-button-label-and-icon", + text = UiStringText("Text type with a really long text as the button label"), + leadingIcon = R.drawable.ic_jetpack_logo_white_24dp, + ), + onClick = {}, + ) + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/compose/components/menu/dropdown/JetpackDropdownMenu.kt b/WordPress/src/main/java/org/wordpress/android/ui/compose/components/menu/dropdown/JetpackDropdownMenu.kt new file mode 100644 index 000000000000..ab48ff30a30b --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/compose/components/menu/dropdown/JetpackDropdownMenu.kt @@ -0,0 +1,279 @@ +package org.wordpress.android.ui.compose.components.menu.dropdown + +import android.content.res.Configuration +import androidx.compose.animation.core.FiniteAnimationSpec +import androidx.compose.animation.core.spring +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.ContentAlpha +import androidx.compose.material.Divider +import androidx.compose.material.Icon +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MenuDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +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.graphics.ColorFilter +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.semantics.Role +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.IntSize +import androidx.compose.ui.unit.DpOffset +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import me.saket.cascade.CascadeColumnScope +import me.saket.cascade.CascadeDropdownMenu +import org.wordpress.android.R +import org.wordpress.android.ui.compose.theme.AppTheme +import org.wordpress.android.ui.compose.utils.uiStringText +import org.wordpress.android.ui.utils.UiString.UiStringText + +@Composable +fun JetpackDropdownMenu( + menuItems: List, + selectedItem: MenuElementData.Item.Single, + onSingleItemClick: (MenuElementData.Item.Single) -> Unit, + menuButtonHeight: Dp = 36.dp, + contentSizeAnimation: FiniteAnimationSpec = spring(), + onDropdownMenuClick: () -> Unit, +) { + Column { + var isMenuVisible by remember { mutableStateOf(false) } + DropdownMenuButton( + height = menuButtonHeight, + contentSizeAnimation = contentSizeAnimation, + selectedItem = selectedItem, + onClick = { + onDropdownMenuClick() + isMenuVisible = !isMenuVisible + } + ) + val cascadeMenuWidth = 200.dp + CascadeDropdownMenu( + modifier = Modifier + .background(MenuColors.itemBackgroundColor()), + expanded = isMenuVisible, + fixedWidth = cascadeMenuWidth, + onDismissRequest = { isMenuVisible = false }, + offset = DpOffset( + x = if (LocalLayoutDirection.current == LayoutDirection.Rtl) cascadeMenuWidth else 0.dp, + y = 0.dp + ) + ) { + val onMenuItemSingleClick: (MenuElementData.Item.Single) -> Unit = { clickedItem -> + isMenuVisible = false + onSingleItemClick(clickedItem) + } + menuItems.forEach { element -> + MenuElementComposable(element = element, onMenuItemSingleClick = onMenuItemSingleClick) + } + } + } +} + +@Composable +private fun CascadeColumnScope.MenuElementComposable( + element: MenuElementData, + onMenuItemSingleClick: (MenuElementData.Item.Single) -> Unit +) { + when (element) { + is MenuElementData.Divider -> Divider( + color = MenuColors.itemDividerColor(), + ) + + is MenuElementData.Item -> { + when (element) { + is MenuElementData.Item.Single -> Single(element, onMenuItemSingleClick) + is MenuElementData.Item.SubMenu -> SubMenu(element, onMenuItemSingleClick) + } + } + } +} + +@Composable +private fun Single( + element: MenuElementData.Item.Single, + onMenuItemSingleClick: (MenuElementData.Item.Single) -> Unit, +) { + val enabledContentColor = MenuColors.itemContentColor() + val disabledContentColor = enabledContentColor.copy(alpha = ContentAlpha.disabled) + DropdownMenuItem( + modifier = Modifier + .background(MenuColors.itemBackgroundColor()), + onClick = { + onMenuItemSingleClick(element) + }, + colors = MenuDefaults.itemColors( + textColor = enabledContentColor, + leadingIconColor = enabledContentColor, + trailingIconColor = enabledContentColor, + disabledTextColor = disabledContentColor, + disabledLeadingIconColor = disabledContentColor, + disabledTrailingIconColor = disabledContentColor, + ), + text = { + Text( + text = uiStringText(element.text), + style = MaterialTheme.typography.bodyLarge, + overflow = TextOverflow.Ellipsis, + fontWeight = FontWeight.Normal, + maxLines = 1, + ) + }, + leadingIcon = if (element.leadingIcon != NO_ICON) { + { + Icon( + painter = painterResource(id = element.leadingIcon), + contentDescription = null, + ) + } + } else null, + ) +} + +@Composable +private fun CascadeColumnScope.SubMenuHeader( + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(10.5.dp), + text: @Composable (() -> Unit)? = null, +) { + Row( + modifier = modifier + .background(MenuColors.itemBackgroundColor()) + .fillMaxWidth() + .clickable(enabled = hasParentMenu, role = Role.Button) { + if (!isNavigationRunning) { + cascadeState.navigateBack() + } + } + .padding(contentPadding), + verticalAlignment = Alignment.CenterVertically, + ) { + CompositionLocalProvider( + LocalTextStyle provides MaterialTheme.typography.bodyLarge + ) { + if (this@SubMenuHeader.hasParentMenu) { + val backIconResource = if(LocalLayoutDirection.current == LayoutDirection.Rtl) { + R.drawable.ic_arrow_right_white_24dp + } else { + R.drawable.ic_arrow_left_white_24dp + } + Image( + painter = painterResource(backIconResource), + contentDescription = null, + colorFilter = ColorFilter.tint(MenuColors.itemContentColor()), + ) + } + Box(Modifier.weight(1f)) { + text?.invoke() + } + } + } +} + +@Composable +private fun CascadeColumnScope.SubMenu( + element: MenuElementData.Item.SubMenu, + onMenuItemSingleClick: (MenuElementData.Item.Single) -> Unit, +) { + val enabledContentColor = MenuColors.itemContentColor() + val disabledContentColor = enabledContentColor.copy(alpha = ContentAlpha.disabled) + DropdownMenuItem( + modifier = Modifier + .background(MenuColors.itemBackgroundColor()), + colors = MenuDefaults.itemColors( + textColor = enabledContentColor, + leadingIconColor = enabledContentColor, + trailingIconColor = enabledContentColor, + disabledTextColor = disabledContentColor, + disabledLeadingIconColor = disabledContentColor, + disabledTrailingIconColor = disabledContentColor, + ), + text = { + Text( + text = uiStringText(element.text), + style = MaterialTheme.typography.bodyLarge, + overflow = TextOverflow.Ellipsis, + fontWeight = FontWeight.Normal, + maxLines = 1, + ) + }, + children = { + element.children.forEach { + MenuElementComposable(element = it, onMenuItemSingleClick = onMenuItemSingleClick) + } + }, + childrenHeader = { + SubMenuHeader() + } + ) +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun JetpackDropdownMenuPreview() { + val menuItems = listOf( + MenuElementData.Item.Single( + id = "text-only", + text = UiStringText("Text only"), + ), + MenuElementData.Item.Single( + id = "text-and-icon", + text = UiStringText("Text and leading icon"), + leadingIcon = R.drawable.ic_jetpack_logo_white_24dp, + ), + MenuElementData.Divider, + MenuElementData.Item.SubMenu( + id = "text-and-sub-menu", + text = UiStringText("Text and sub-menu"), + children = listOf( + MenuElementData.Item.Single( + id = "text-sub-menu-1", + text = UiStringText("Text sub-menu 1"), + ), + MenuElementData.Item.Single( + id = "text-sub-menu-2", + text = UiStringText("Text sub-menu 2"), + ) + ) + ), + ) + var selectedItem by remember { mutableStateOf(menuItems.first() as MenuElementData.Item.Single) } + + AppTheme { + Box( + modifier = Modifier + .padding(start = 8.dp, top = 8.dp) + .fillMaxWidth() + .fillMaxHeight() + ) { + JetpackDropdownMenu( + selectedItem = selectedItem, + menuItems = menuItems, + onSingleItemClick = { selectedItem = it }, + onDropdownMenuClick = {}, + ) + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/compose/components/menu/dropdown/MenuColors.kt b/WordPress/src/main/java/org/wordpress/android/ui/compose/components/menu/dropdown/MenuColors.kt new file mode 100644 index 000000000000..783bee09307b --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/compose/components/menu/dropdown/MenuColors.kt @@ -0,0 +1,30 @@ +package org.wordpress.android.ui.compose.components.menu.dropdown + +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import org.wordpress.android.ui.compose.theme.AppColor +import org.wordpress.android.ui.compose.utils.isLightTheme + +object MenuColors { + @Composable + fun itemContentColor(): Color = if (isLightTheme()) { + AppColor.Black + } else { + AppColor.White + } + + @Composable + fun itemBackgroundColor(): Color = if (isLightTheme()) { + AppColor.White + } else { + AppColor.DarkGray90 + } + + @Composable + fun itemDividerColor(): Color = if (isLightTheme()) { + MaterialTheme.colors.onSurface.copy(alpha = 0.12f) + } else { + AppColor.Gray50 + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/compose/components/menu/dropdown/MenuElementData.kt b/WordPress/src/main/java/org/wordpress/android/ui/compose/components/menu/dropdown/MenuElementData.kt new file mode 100644 index 000000000000..120050229319 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/compose/components/menu/dropdown/MenuElementData.kt @@ -0,0 +1,31 @@ +package org.wordpress.android.ui.compose.components.menu.dropdown + +import androidx.annotation.DrawableRes +import org.wordpress.android.ui.utils.UiString + +sealed interface MenuElementData { + data object Divider : MenuElementData + + sealed class Item( + open val id: String, + open val text: UiString, + @DrawableRes open val leadingIcon: Int, + ) : MenuElementData { + // Item element that closes the menu when clicked + data class Single( + override val id: String, + override val text: UiString, + @DrawableRes override val leadingIcon: Int = NO_ICON, + ) : Item(id, text, leadingIcon) + + // Sub-menu element that opens a sub-menu when clicked + data class SubMenu( + override val id: String, + override val text: UiString, + val children: List, + @DrawableRes override val leadingIcon: Int = NO_ICON, + ) : Item(id, text, leadingIcon) + } +} + +internal const val NO_ICON = -1 diff --git a/WordPress/src/main/java/org/wordpress/android/ui/compose/theme/AppColor.kt b/WordPress/src/main/java/org/wordpress/android/ui/compose/theme/AppColor.kt index cf654d53b3f7..6e83c1ee699c 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/compose/theme/AppColor.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/compose/theme/AppColor.kt @@ -20,6 +20,12 @@ object AppColor { @Stable val DarkGray = Color(0xFF121212) + @Stable + val DarkGray90 = Color(0xE6121212) + + @Stable + val Gray10 = Color(0xFFC3C4C7) + @Stable val Gray30 = Color(0xFF8c8f94) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/compose/utils/ComposeUtils.kt b/WordPress/src/main/java/org/wordpress/android/ui/compose/utils/ComposeUtils.kt index 9c3ca0393396..a70a1db27ad4 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/compose/utils/ComposeUtils.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/compose/utils/ComposeUtils.kt @@ -4,6 +4,7 @@ import android.annotation.SuppressLint import android.content.res.Configuration import android.os.Build import androidx.compose.material.LocalContentAlpha +import androidx.compose.material.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.platform.LocalContext @@ -70,3 +71,10 @@ fun LocaleAwareComposable( content() } } + +/** + * Indicates whether the currently selected theme is light. + * @return true if the current theme is light + */ +@Composable +fun isLightTheme(): Boolean = MaterialTheme.colors.isLight diff --git a/WordPress/src/main/java/org/wordpress/android/ui/compose/utils/FadingEdgesModifier.kt b/WordPress/src/main/java/org/wordpress/android/ui/compose/utils/FadingEdgesModifier.kt new file mode 100644 index 000000000000..8d0e0b323a5c --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/compose/utils/FadingEdgesModifier.kt @@ -0,0 +1,149 @@ +package org.wordpress.android.ui.compose.utils + +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.verticalScroll +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import kotlin.math.min + +// Fading edges for scrollable containers based on +// https://medium.com/@helmersebastian/fading-edges-modifier-in-jetpack-compose-af94159fdf1f + +/** + * Adds vertical fading edges to a scrollable container, usually a [Column]. + * It needs to be used right after the scrollable modifier [verticalScroll]. + * + * The [topEdgeSize] defines the maximum size of the fading edge effect, which is used when that same amount of the + * content is outside the scrollable area. + * + * @param scrollState the scroll state of the scrollable container (same used in [Modifier.verticalScroll]) + * @param topEdgeSize the size of the fading edge on the top + * @param bottomEdgeSize the size of the fading edge on the bottom + */ +@Suppress("MagicNumber", "Unused") +fun Modifier.verticalFadingEdges( + scrollState: ScrollState, + topEdgeSize: Dp = 72.dp, + bottomEdgeSize: Dp = 72.dp, +): Modifier = this + // adding layer fixes issue with blending gradient and content + .graphicsLayer { alpha = 0.999F } + .drawWithContent { + drawContent() + + val scrollAreaHeight = size.height - scrollState.maxValue + val scrollAreaTopY = scrollState.value.toFloat() + val scrollAreaBottomY = scrollAreaTopY + scrollAreaHeight + + // gradient size is equivalent to how much content is outside of the area in each side limited by edgeSize + val topGradientHeight = min(topEdgeSize.toPx(), scrollState.value.toFloat()) + val bottomGradientHeight = min(bottomEdgeSize.toPx(), scrollState.maxValue.toFloat() - scrollState.value) + + // wherever the rectangle is drawn (green), the content will be transparent, creating the fading effect + val topColors = listOf(Color.Green, Color.Transparent) + val bottomColors = listOf(Color.Transparent, Color.Green) + + if (topGradientHeight != 0f) { + drawRect( + brush = Brush.verticalGradient( + colors = topColors, + startY = scrollAreaTopY, + endY = scrollAreaTopY + topGradientHeight + ), + blendMode = BlendMode.DstOut + ) + } + + if (bottomGradientHeight != 0f) { + drawRect( + brush = Brush.verticalGradient( + colors = bottomColors, + startY = scrollAreaBottomY - bottomGradientHeight, + endY = scrollAreaBottomY + ), + blendMode = BlendMode.DstOut + ) + } + } + +/** + * Adds horizontal fading edges to a scrollable container, usually a [Row]. + * It needs to be used right after the scrollable modifier [horizontalScroll]. + * + * The [edgeSize] defines the maximum size of the fading edge effect, which is used when that same amount of the + * content is outside the scrollable area. + * + * @param scrollState the scroll state of the scrollable container (same used in [Modifier.horizontalScroll]) + * @param startEdgeSize the size of the fading edge on the start + * @param endEdgeSize the size of the fading edge on the end + */ +@Suppress("MagicNumber", "Unused") +fun Modifier.horizontalFadingEdges( + scrollState: ScrollState, + startEdgeSize: Dp = 24.dp, + endEdgeSize: Dp = 24.dp, +): Modifier = this + // adding layer fixes issue with blending gradient and content + .graphicsLayer { alpha = 0.99F } + .composed { + val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl + + drawWithContent { + drawContent() + + val scrollAreaWidth = size.width - scrollState.maxValue + val scrollAreaLeftX: Float + val scrollAreaRightX: Float + val leftGradientWidth: Float + val rightGradientWidth: Float + + if (!isRtl) { + scrollAreaLeftX = scrollState.value.toFloat() + scrollAreaRightX = scrollAreaLeftX + scrollAreaWidth + leftGradientWidth = min(startEdgeSize.toPx(), scrollState.value.toFloat()) + rightGradientWidth = min(endEdgeSize.toPx(), scrollState.maxValue.toFloat() - scrollState.value) + } else { + scrollAreaRightX = size.width - scrollState.value.toFloat() + scrollAreaLeftX = scrollAreaRightX - scrollAreaWidth + leftGradientWidth = min(endEdgeSize.toPx(), scrollState.maxValue.toFloat() - scrollState.value) + rightGradientWidth = min(startEdgeSize.toPx(), scrollState.value.toFloat()) + } + + val leftColors = listOf(Color.Green, Color.Transparent) + val rightColors = listOf(Color.Transparent, Color.Green) + + if (leftGradientWidth != 0f) { + drawRect( + brush = Brush.horizontalGradient( + colors = leftColors, + startX = scrollAreaLeftX, + endX = scrollAreaLeftX + leftGradientWidth + ), + blendMode = BlendMode.DstOut, + ) + } + + if (rightGradientWidth != 0f) { + drawRect( + brush = Brush.horizontalGradient( + colors = rightColors, + startX = scrollAreaRightX - rightGradientWidth, + endX = scrollAreaRightX + ), + blendMode = BlendMode.DstOut, + ) + } + } + } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/main/WPMainActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/main/WPMainActivity.java index dc03af478cf0..e20b85c298d4 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/main/WPMainActivity.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/main/WPMainActivity.java @@ -29,11 +29,9 @@ import com.google.android.gms.common.GoogleApiAvailability; import com.google.android.gms.tasks.Task; import com.google.android.material.floatingactionbutton.FloatingActionButton; -import com.google.android.play.core.review.ReviewException; import com.google.android.play.core.review.ReviewInfo; import com.google.android.play.core.review.ReviewManager; import com.google.android.play.core.review.ReviewManagerFactory; -import com.google.android.play.core.review.model.ReviewErrorCode; import org.greenrobot.eventbus.EventBus; import org.greenrobot.eventbus.Subscribe; @@ -184,6 +182,7 @@ import static org.wordpress.android.login.LoginAnalyticsListener.CreatedAccountSource.EMAIL; import static org.wordpress.android.push.NotificationsProcessingService.ARG_NOTIFICATION_TYPE; import static org.wordpress.android.ui.JetpackConnectionSource.NOTIFICATIONS; +import static org.wordpress.android.util.extensions.InAppReviewExtensionsKt.logException; import dagger.hilt.android.AndroidEntryPoint; @@ -231,12 +230,10 @@ public class WPMainActivity extends LocaleAwareActivity implements public static final String ARG_STAT_TO_TRACK = "stat_to_track"; public static final String ARG_EDITOR_ORIGIN = "editor_origin"; public static final String ARG_CURRENT_FOCUS = "CURRENT_FOCUS"; + public static final String ARG_IS_CHANGING_CONFIGURATION = "IS_CHANGING_CONFIGURATION"; public static final String ARG_BYPASS_MIGRATION = "bypass_migration"; public static final String ARG_MEDIA = "show_media"; - - // Track the first `onResume` event for the current session so we can use it for Analytics tracking - private static boolean mFirstResume = true; - + private boolean mIsChangingConfiguration = false; private WPMainNavigationView mBottomNav; private TextView mConnectionBar; @@ -504,6 +501,10 @@ && getIntent().getExtras().getBoolean(ARG_CONTINUE_JETPACK_CONNECT, false)) { } displayJetpackFeatureCollectionOverlayIfNeeded(); + + if (savedInstanceState != null) { + mIsChangingConfiguration = savedInstanceState.getBoolean(ARG_IS_CHANGING_CONFIGURATION, false); + } } private void initBackPressHandler() { @@ -658,6 +659,7 @@ private void scheduleLocalNotifications() { @Override protected void onSaveInstanceState(@NonNull Bundle outState) { outState.putSerializable(ARG_CURRENT_FOCUS, mCurrentActiveFocusPoint); + outState.putSerializable(ARG_IS_CHANGING_CONFIGURATION, isChangingConfigurations()); super.onSaveInstanceState(outState); } @@ -794,18 +796,10 @@ private void initViewModel() { }); }); - mViewModel.getStartLoginFlow().observe(this, event -> { - event.applyIfNotHandled(unit -> { - ActivityLauncher.viewMeActivityForResult(this); - - return null; - }); - }); - - mViewModel.getSwitchToMySite().observe(this, event -> { + mViewModel.getSwitchToMeTab().observe(this, event -> { event.applyIfNotHandled(unit -> { if (mBottomNav != null) { - mBottomNav.setCurrentSelectedPage(PageType.MY_SITE); + mBottomNav.setCurrentSelectedPage(PageType.ME); } return null; @@ -854,12 +848,7 @@ private void launchInAppReviews() { Task flow = manager.launchReviewFlow(this, reviewInfo); flow.addOnFailureListener(e -> AppLog.e(T.MAIN, "Error launching google review API flow.", e)); } else { - @ReviewErrorCode int reviewErrorCode = ((ReviewException) task.getException()).getErrorCode(); - AppLog.e( - T.MAIN, - "Error fetching ReviewInfo object from Review API to start in-app review process", - reviewErrorCode - ); + logException(task); } }); } @@ -1135,7 +1124,10 @@ protected void onResume() { if (mBottomNav != null) { PageType currentPageType = mBottomNav.getCurrentSelectedPage(); - trackLastVisiblePage(currentPageType, mFirstResume); + if (!mIsChangingConfiguration) { + // Don't track if onResume was called after a screen orientation change + trackLastVisiblePage(currentPageType); + } if (currentPageType == PageType.NOTIFS) { // if we are presenting the notifications list, it's safe to clear any outstanding @@ -1167,8 +1159,7 @@ protected void onResume() { mSelectedSiteRepository.hasSelectedSite() && mBottomNav != null && mBottomNav.getCurrentSelectedPage() == PageType.MY_SITE ); - - mFirstResume = false; + mIsChangingConfiguration = false; } private void checkQuickStartNotificationStatus() { @@ -1214,7 +1205,7 @@ private void showBottomNav(boolean show) { public void onPageChanged(int position) { mReaderTracker.onBottomNavigationTabChanged(); PageType pageType = WPMainNavigationView.getPageType(position); - trackLastVisiblePage(pageType, true); + trackLastVisiblePage(pageType); mCurrentActiveFocusPoint = null; if (pageType == PageType.READER) { // MySite fragment might not be attached to activity, so we need to remove focus point from here @@ -1288,31 +1279,23 @@ private void handleNewStoryAction() { } } - private void trackLastVisiblePage(PageType pageType, boolean trackAnalytics) { + private void trackLastVisiblePage(@NonNull final PageType pageType) { switch (pageType) { case MY_SITE: ActivityId.trackLastActivity(ActivityId.MY_SITE); - if (trackAnalytics) { - mAnalyticsTrackerWrapper.track(AnalyticsTracker.Stat.MY_SITE_ACCESSED, getSelectedSite()); - } + mAnalyticsTrackerWrapper.track(AnalyticsTracker.Stat.MY_SITE_ACCESSED, getSelectedSite()); break; case READER: ActivityId.trackLastActivity(ActivityId.READER); - if (trackAnalytics) { - AnalyticsTracker.track(AnalyticsTracker.Stat.READER_ACCESSED); - } + AnalyticsTracker.track(AnalyticsTracker.Stat.READER_ACCESSED); break; case NOTIFS: ActivityId.trackLastActivity(ActivityId.NOTIFICATIONS); - if (trackAnalytics) { - AnalyticsTracker.track(AnalyticsTracker.Stat.NOTIFICATIONS_ACCESSED); - } + AnalyticsTracker.track(AnalyticsTracker.Stat.NOTIFICATIONS_ACCESSED); break; case ME: ActivityId.trackLastActivity(ActivityId.ME); - if (trackAnalytics) { - AnalyticsTracker.track(Stat.ME_ACCESSED); - } + AnalyticsTracker.track(Stat.ME_ACCESSED); break; default: break; diff --git a/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/blaze/BlazeStatusLabel.kt b/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/blaze/BlazeStatusLabel.kt index 170fb0cc9e5c..760af14e7d74 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/blaze/BlazeStatusLabel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/mysite/cards/blaze/BlazeStatusLabel.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.layout.wrapContentWidth 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 @@ -17,13 +16,14 @@ 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 org.wordpress.android.ui.compose.utils.isLightTheme @Composable fun BlazeStatusLabel( status: CampaignStatus, modifier: Modifier = Modifier ) { - val isInDarkMode = !MaterialTheme.colors.isLight + val isInDarkMode = !isLightTheme() Box( modifier = modifier .padding(top = 8.dp) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/mysite/items/listitem/SiteItemsBuilder.kt b/WordPress/src/main/java/org/wordpress/android/ui/mysite/items/listitem/SiteItemsBuilder.kt index fb3fcc0fdad5..2727c43d1e2a 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/mysite/items/listitem/SiteItemsBuilder.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/mysite/items/listitem/SiteItemsBuilder.kt @@ -91,8 +91,8 @@ class SiteItemsBuilder @Inject constructor( private fun getManageSiteItems( params: SiteItemsBuilderParams ): List { - val siteMonitoring = buildSiteMonitoringOptionsIfNeeded(params) val manageSiteItems = buildManageSiteItems(params) + val siteMonitoring = buildSiteMonitoringOptionsIfNeeded(params) val emptyHeaderItem1 = CategoryEmptyHeaderItem(UiString.UiStringText("")) val jetpackConfiguration = buildJetpackDependantConfigurationItemsIfNeeded(params) @@ -102,8 +102,8 @@ class SiteItemsBuilder @Inject constructor( val emptyHeaderItem2 = CategoryEmptyHeaderItem(UiString.UiStringText("")) val admin = siteListItemBuilder.buildAdminItemIfAvailable(params.site, params.onClick) return listOf(manageHeader) + - siteMonitoring + manageSiteItems + + siteMonitoring + emptyHeaderItem1 + jetpackConfiguration + lookAndFeel + diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsDetailActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsDetailActivity.java index 0dc9e1d6f0da..18cbb239c3df 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsDetailActivity.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsDetailActivity.java @@ -382,7 +382,7 @@ private NotificationDetailFragmentAdapter buildNoteListAdapterAndSetPosition(Not ArrayList filteredNotes = new ArrayList<>(); // apply filter to the list so we show the same items that the list show vertically, but horizontally - NotesAdapter.buildFilteredNotesList(filteredNotes, notes, filter); + NotesAdapter.Companion.buildFilteredNotesList(filteredNotes, notes, filter); adapter = new NotificationDetailFragmentAdapter(getSupportFragmentManager(), filteredNotes); if (mBinding != null) { diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsListFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsListFragment.kt index 459dd85958d1..8956530b7307 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsListFragment.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/notifications/NotificationsListFragment.kt @@ -3,15 +3,17 @@ package org.wordpress.android.ui.notifications import android.Manifest +import android.annotation.SuppressLint import android.app.Activity import android.content.Intent import android.os.Build import android.os.Bundle import android.text.TextUtils +import android.view.LayoutInflater import android.view.Menu import android.view.MenuInflater -import android.view.MenuItem import android.view.View +import android.widget.PopupWindow import androidx.annotation.StringRes import androidx.appcompat.app.AppCompatActivity import androidx.core.text.HtmlCompat @@ -30,6 +32,8 @@ import org.greenrobot.eventbus.EventBus import org.wordpress.android.R import org.wordpress.android.analytics.AnalyticsTracker import org.wordpress.android.analytics.AnalyticsTracker.NOTIFICATIONS_SELECTED_FILTER +import org.wordpress.android.analytics.AnalyticsTracker.Stat.NOTIFICATIONS_MARK_ALL_READ_TAPPED +import org.wordpress.android.analytics.AnalyticsTracker.Stat.NOTIFICATION_MENU_TAPPED import org.wordpress.android.analytics.AnalyticsTracker.Stat.NOTIFICATION_TAPPED_SEGMENTED_CONTROL import org.wordpress.android.databinding.NotificationsListFragmentBinding import org.wordpress.android.fluxc.store.AccountStore @@ -63,6 +67,7 @@ import org.wordpress.android.util.PermissionUtils import org.wordpress.android.util.WPPermissionUtils import org.wordpress.android.util.WPPermissionUtils.NOTIFICATIONS_PERMISSION_REQUEST_CODE import org.wordpress.android.util.WPUrlUtils +import org.wordpress.android.util.analytics.AnalyticsTrackerWrapper import org.wordpress.android.util.extensions.setLiftOnScrollTargetViewIdAndRequestLayout import org.wordpress.android.viewmodel.observeEvent import javax.inject.Inject @@ -78,6 +83,9 @@ class NotificationsListFragment : Fragment(R.layout.notifications_list_fragment) @Inject lateinit var uiHelpers: UiHelpers + @Inject + lateinit var analyticsTrackerWrapper: AnalyticsTrackerWrapper + private val viewModel: NotificationsListViewModel by viewModels() private var shouldRefreshNotifications = false @@ -280,8 +288,12 @@ class NotificationsListFragment : Fragment(R.layout.notifications_list_fragment) @Suppress("OVERRIDE_DEPRECATION") override fun onPrepareOptionsMenu(menu: Menu) { - val notificationSettings = menu.findItem(R.id.notifications_settings) - notificationSettings.isVisible = accountStore.hasAccessToken() + val notificationActions = menu.findItem(R.id.notifications_actions) + notificationActions.isVisible = accountStore.hasAccessToken() + notificationActions.actionView?.setOnClickListener { + analyticsTrackerWrapper.track(NOTIFICATION_MENU_TAPPED) + showNotificationActionsPopup(it) + } super.onPrepareOptionsMenu(menu) } @@ -291,13 +303,34 @@ class NotificationsListFragment : Fragment(R.layout.notifications_list_fragment) super.onCreateOptionsMenu(menu, inflater) } - @Suppress("OVERRIDE_DEPRECATION") - override fun onOptionsItemSelected(item: MenuItem): Boolean { - if (item.itemId == R.id.notifications_settings) { - ActivityLauncher.viewNotificationsSettings(activity) - return true - } - return super.onOptionsItemSelected(item) + /** + * For displaying the popup of notifications settings + */ + @SuppressLint("InflateParams") + private fun showNotificationActionsPopup(anchorView: View) { + val popupWindow = PopupWindow(requireContext(), null, R.style.WordPress) + popupWindow.isOutsideTouchable = true + popupWindow.elevation = resources.getDimension(R.dimen.popup_over_toolbar_elevation) + popupWindow.contentView = LayoutInflater.from(requireContext()) + .inflate(R.layout.notification_actions, null).apply { + findViewById(R.id.text_mark_all_as_read).setOnClickListener { + markAllAsRead() + popupWindow.dismiss() + } + findViewById(R.id.text_settings).setOnClickListener { + ActivityLauncher.viewNotificationsSettings(activity) + popupWindow.dismiss() + } + } + popupWindow.showAsDropDown(anchorView) + } + + /** + * For marking the status of every notification as read + */ + private fun markAllAsRead() { + analyticsTrackerWrapper.track(NOTIFICATIONS_MARK_ALL_READ_TAPPED) + // TODO("not yet implemented") } companion object { diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/adapters/NotesAdapter.java b/WordPress/src/main/java/org/wordpress/android/ui/notifications/adapters/NotesAdapter.java deleted file mode 100644 index 926be56da0ad..000000000000 --- a/WordPress/src/main/java/org/wordpress/android/ui/notifications/adapters/NotesAdapter.java +++ /dev/null @@ -1,412 +0,0 @@ -package org.wordpress.android.ui.notifications.adapters; - -import android.annotation.SuppressLint; -import android.content.Context; -import android.os.AsyncTask; -import android.os.AsyncTask.Status; -import android.text.Spanned; -import android.text.TextUtils; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.view.ViewGroup.MarginLayoutParams; -import android.view.ViewParent; -import android.view.ViewTreeObserver; -import android.widget.ImageView; -import android.widget.TextView; - -import androidx.core.text.BidiFormatter; -import androidx.core.view.ViewCompat; -import androidx.recyclerview.widget.RecyclerView; - -import org.wordpress.android.R; -import org.wordpress.android.WordPress; -import org.wordpress.android.datasets.NotificationsTable; -import org.wordpress.android.fluxc.model.CommentStatus; -import org.wordpress.android.models.Note; -import org.wordpress.android.ui.comments.CommentUtils; -import org.wordpress.android.ui.notifications.NotificationsListFragmentPage.OnNoteClickListener; -import org.wordpress.android.ui.notifications.blocks.NoteBlockClickableSpan; -import org.wordpress.android.ui.notifications.utils.NotificationsUtilsWrapper; -import org.wordpress.android.util.GravatarUtils; -import org.wordpress.android.util.RtlUtils; -import org.wordpress.android.util.image.ImageManager; -import org.wordpress.android.util.image.ImageType; - -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; - -import javax.inject.Inject; - -public class NotesAdapter extends RecyclerView.Adapter { - private final int mAvatarSz; - private final int mTextIndentSize; - - private final DataLoadedListener mDataLoadedListener; - private final OnLoadMoreListener mOnLoadMoreListener; - private final ArrayList mNotes = new ArrayList<>(); - private final ArrayList mFilteredNotes = new ArrayList<>(); - @Inject protected ImageManager mImageManager; - @Inject protected NotificationsUtilsWrapper mNotificationsUtilsWrapper; - - public enum FILTERS { - FILTER_ALL, - FILTER_COMMENT, - FILTER_FOLLOW, - FILTER_LIKE, - FILTER_UNREAD; - - public String toString() { - switch (this) { - case FILTER_ALL: - return "all"; - case FILTER_COMMENT: - return "comment"; - case FILTER_FOLLOW: - return "follow"; - case FILTER_LIKE: - return "like"; - case FILTER_UNREAD: - return "unread"; - default: - return "all"; - } - } - } - - private FILTERS mCurrentFilter = FILTERS.FILTER_ALL; - private ReloadNotesFromDBTask mReloadNotesFromDBTask; - - public interface DataLoadedListener { - void onDataLoaded(int itemsCount); - } - - public interface OnLoadMoreListener { - void onLoadMore(long timestamp); - } - - private OnNoteClickListener mOnNoteClickListener; - - public NotesAdapter(Context context, DataLoadedListener dataLoadedListener, OnLoadMoreListener onLoadMoreListener) { - super(); - ((WordPress) context.getApplicationContext()).component().inject(this); - mDataLoadedListener = dataLoadedListener; - mOnLoadMoreListener = onLoadMoreListener; - - // this is on purpose - we don't show more than a hundred or so notifications at a time so no need to set - // stable IDs. This helps prevent crashes in case a note comes with no ID (we've code checking for that - // elsewhere, but telling the RecyclerView.Adapter the notes have stable Ids and then failing to provide them - // will make things go south as in https://github.com/wordpress-mobile/WordPress-Android/issues/8741 - setHasStableIds(false); - - mAvatarSz = (int) context.getResources().getDimension(R.dimen.notifications_avatar_sz); - mTextIndentSize = context.getResources().getDimensionPixelSize(R.dimen.notifications_text_indent_sz); - } - - public void setFilter(FILTERS newFilter) { - mCurrentFilter = newFilter; - } - - public FILTERS getCurrentFilter() { - return mCurrentFilter; - } - - public void addAll(List notes, boolean clearBeforeAdding) { - Collections.sort(notes, new Note.TimeStampComparator()); - try { - if (clearBeforeAdding) { - mNotes.clear(); - } - mNotes.addAll(notes); - } finally { - myNotifyDatasetChanged(); - } - } - - private void myNotifyDatasetChanged() { - buildFilteredNotesList(mFilteredNotes, mNotes, mCurrentFilter); - notifyDataSetChanged(); - if (mDataLoadedListener != null) { - mDataLoadedListener.onDataLoaded(getItemCount()); - } - } - - @Override - public NoteViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { - View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.notifications_list_item, parent, false); - - return new NoteViewHolder(view); - } - - // Instead of building the filtered notes list dynamically, create it once and re-use it. - // Otherwise it's re-created so many times during layout. - public static void buildFilteredNotesList(ArrayList filteredNotes, ArrayList notes, FILTERS filter) { - filteredNotes.clear(); - if (notes.isEmpty() || filter == FILTERS.FILTER_ALL) { - filteredNotes.addAll(notes); - return; - } - for (Note currentNote : notes) { - switch (filter) { - case FILTER_COMMENT: - if (currentNote.isCommentType()) { - filteredNotes.add(currentNote); - } - break; - case FILTER_FOLLOW: - if (currentNote.isFollowType()) { - filteredNotes.add(currentNote); - } - break; - case FILTER_UNREAD: - if (currentNote.isUnread()) { - filteredNotes.add(currentNote); - } - break; - case FILTER_LIKE: - if (currentNote.isLikeType()) { - filteredNotes.add(currentNote); - } - break; - } - } - } - - private Note getNoteAtPosition(int position) { - if (isValidPosition(position)) { - return mFilteredNotes.get(position); - } - - return null; - } - - private boolean isValidPosition(int position) { - return (position >= 0 && position < mFilteredNotes.size()); - } - - @Override - public int getItemCount() { - return mFilteredNotes.size(); - } - - @Override - public void onBindViewHolder(NoteViewHolder noteViewHolder, int position) { - final Note note = getNoteAtPosition(position); - if (note == null) { - return; - } - noteViewHolder.mContentView.setTag(note.getId()); - - // Display group header - Note.NoteTimeGroup timeGroup = Note.getTimeGroupForTimestamp(note.getTimestamp()); - - Note.NoteTimeGroup previousTimeGroup = null; - if (position > 0) { - Note previousNote = getNoteAtPosition(position - 1); - previousTimeGroup = Note.getTimeGroupForTimestamp(previousNote.getTimestamp()); - } - - if (previousTimeGroup != null && previousTimeGroup == timeGroup) { - noteViewHolder.mHeaderText.setVisibility(View.GONE); - } else { - noteViewHolder.mHeaderText.setVisibility(View.VISIBLE); - - if (timeGroup == Note.NoteTimeGroup.GROUP_TODAY) { - noteViewHolder.mHeaderText.setText(R.string.stats_timeframe_today); - } else if (timeGroup == Note.NoteTimeGroup.GROUP_YESTERDAY) { - noteViewHolder.mHeaderText.setText(R.string.stats_timeframe_yesterday); - } else if (timeGroup == Note.NoteTimeGroup.GROUP_OLDER_TWO_DAYS) { - noteViewHolder.mHeaderText.setText(R.string.older_two_days); - } else if (timeGroup == Note.NoteTimeGroup.GROUP_OLDER_WEEK) { - noteViewHolder.mHeaderText.setText(R.string.older_last_week); - } else { - noteViewHolder.mHeaderText.setText(R.string.older_month); - } - } - - CommentStatus commentStatus = CommentStatus.ALL; - if (note.getCommentStatus() == CommentStatus.UNAPPROVED) { - commentStatus = CommentStatus.UNAPPROVED; - } - - if (!TextUtils.isEmpty(note.getLocalStatus())) { - commentStatus = CommentStatus.fromString(note.getLocalStatus()); - } - - // Subject is stored in db as html to preserve text formatting - Spanned noteSubjectSpanned = note.getFormattedSubject(mNotificationsUtilsWrapper); - // Trim the '\n\n' added by HtmlCompat.fromHtml(...) - noteSubjectSpanned = - (Spanned) noteSubjectSpanned.subSequence(0, TextUtils.getTrimmedLength(noteSubjectSpanned)); - - NoteBlockClickableSpan[] spans = - noteSubjectSpanned.getSpans(0, noteSubjectSpanned.length(), NoteBlockClickableSpan.class); - for (NoteBlockClickableSpan span : spans) { - span.enableColors(noteViewHolder.mContentView.getContext()); - } - - noteViewHolder.mTxtSubject.setText(noteSubjectSpanned); - - String noteSubjectNoticon = note.getCommentSubjectNoticon(); - if (!TextUtils.isEmpty(noteSubjectNoticon)) { - ViewParent parent = noteViewHolder.mTxtSubject.getParent(); - // Fix position of the subject noticon in the RtL mode - if (parent instanceof ViewGroup) { - int textDirection = BidiFormatter.getInstance().isRtl(noteViewHolder.mTxtSubject.getText()) - ? ViewCompat.LAYOUT_DIRECTION_RTL : ViewCompat.LAYOUT_DIRECTION_LTR; - ViewCompat.setLayoutDirection((ViewGroup) parent, textDirection); - } - // mirror noticon in the rtl mode - if (RtlUtils.isRtl(noteViewHolder.itemView.getContext())) { - noteViewHolder.mTxtSubjectNoticon.setScaleX(-1); - } - CommentUtils.indentTextViewFirstLine(noteViewHolder.mTxtSubject, mTextIndentSize); - noteViewHolder.mTxtSubjectNoticon.setText(noteSubjectNoticon); - noteViewHolder.mTxtSubjectNoticon.setVisibility(View.VISIBLE); - } else { - noteViewHolder.mTxtSubjectNoticon.setVisibility(View.GONE); - } - - String noteSnippet = note.getCommentSubject(); - - if (!TextUtils.isEmpty(noteSnippet)) { - handleMaxLines(noteViewHolder.mTxtSubject, noteViewHolder.mTxtDetail); - noteViewHolder.mTxtDetail.setText(noteSnippet); - noteViewHolder.mTxtDetail.setVisibility(View.VISIBLE); - } else { - noteViewHolder.mTxtDetail.setVisibility(View.GONE); - } - - String avatarUrl = GravatarUtils.fixGravatarUrl(note.getIconURL(), mAvatarSz); - mImageManager.loadIntoCircle(noteViewHolder.mImgAvatar, ImageType.AVATAR_WITH_BACKGROUND, avatarUrl); - - if (note.isUnread()) { - noteViewHolder.mUnreadNotificationView.setVisibility(View.VISIBLE); - } else { - noteViewHolder.mUnreadNotificationView.setVisibility(View.GONE); - } - - // request to load more comments when we near the end - if (mOnLoadMoreListener != null && position >= getItemCount() - 1) { - mOnLoadMoreListener.onLoadMore(note.getTimestamp()); - } - - final int headerMarginTop; - final Context context = noteViewHolder.itemView.getContext(); - if (position == 0) { - headerMarginTop = context.getResources() - .getDimensionPixelSize(R.dimen.notifications_header_margin_top_position_0); - } else { - headerMarginTop = context.getResources() - .getDimensionPixelSize(R.dimen.notifications_header_margin_top_position_n); - } - MarginLayoutParams layoutParams = (MarginLayoutParams) noteViewHolder.mHeaderText.getLayoutParams(); - layoutParams.topMargin = headerMarginTop; - noteViewHolder.mHeaderText.setLayoutParams(layoutParams); - - handleInlineActions(noteViewHolder, note); - } - - private void handleInlineActions(final NoteViewHolder noteViewHolder, final Note note) { - note.getType(); - } - - private void handleMaxLines(final TextView subject, final TextView detail) { - subject.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() { - @Override public boolean onPreDraw() { - subject.getViewTreeObserver().removeOnPreDrawListener(this); - if (subject.getLineCount() == 2) { - detail.setMaxLines(1); - } else { - detail.setMaxLines(2); - } - return false; - } - }); - } - - private int getPositionForNoteUnfiltered(String noteId) { - return getPositionForNoteInArray(noteId, mNotes); - } - - private int getPositionForNoteInArray(String noteId, ArrayList notes) { - if (notes != null && noteId != null) { - for (int i = 0; i < notes.size(); i++) { - String noteKey = notes.get(i).getId(); - if (noteKey != null && noteKey.equals(noteId)) { - return i; - } - } - } - return RecyclerView.NO_POSITION; - } - - public void setOnNoteClickListener(OnNoteClickListener mNoteClickListener) { - mOnNoteClickListener = mNoteClickListener; - } - - public void cancelReloadNotesTask() { - if (mReloadNotesFromDBTask != null && mReloadNotesFromDBTask.getStatus() != Status.FINISHED) { - mReloadNotesFromDBTask.cancel(true); - mReloadNotesFromDBTask = null; - } - } - - @SuppressWarnings("deprecation") - public void reloadNotesFromDBAsync() { - cancelReloadNotesTask(); - mReloadNotesFromDBTask = new ReloadNotesFromDBTask(); - mReloadNotesFromDBTask.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR); - } - - @SuppressWarnings("deprecation") - @SuppressLint("StaticFieldLeak") - private class ReloadNotesFromDBTask extends AsyncTask> { - @Override - protected ArrayList doInBackground(Void... voids) { - return NotificationsTable.getLatestNotes(); - } - - @Override - protected void onPostExecute(ArrayList notes) { - mNotes.clear(); - mNotes.addAll(notes); - myNotifyDatasetChanged(); - } - } - - class NoteViewHolder extends RecyclerView.ViewHolder { - private final View mContentView; - private final TextView mHeaderText; - private final TextView mTxtSubject; - private final TextView mTxtSubjectNoticon; - private final TextView mTxtDetail; - private final ImageView mImgAvatar; - private final View mUnreadNotificationView; - private final ImageView mAction; - - NoteViewHolder(View view) { - super(view); - mContentView = view.findViewById(R.id.note_content_container); - mHeaderText = view.findViewById(R.id.header_text); - mTxtSubject = view.findViewById(R.id.note_subject); - mTxtSubjectNoticon = view.findViewById(R.id.note_subject_noticon); - mTxtDetail = view.findViewById(R.id.note_detail); - mImgAvatar = view.findViewById(R.id.note_avatar); - mUnreadNotificationView = view.findViewById(R.id.notification_unread); - mAction = view.findViewById(R.id.action); - - mContentView.setOnClickListener(mOnClickListener); - } - } - - private final View.OnClickListener mOnClickListener = new View.OnClickListener() { - @Override - public void onClick(View v) { - if (mOnNoteClickListener != null && v.getTag() instanceof String) { - mOnNoteClickListener.onClickNote((String) v.getTag()); - } - } - }; -} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/notifications/adapters/NotesAdapter.kt b/WordPress/src/main/java/org/wordpress/android/ui/notifications/adapters/NotesAdapter.kt new file mode 100644 index 000000000000..905e5275c57a --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/notifications/adapters/NotesAdapter.kt @@ -0,0 +1,350 @@ +@file:Suppress("DEPRECATION") + +package org.wordpress.android.ui.notifications.adapters + +import android.annotation.SuppressLint +import android.content.Context +import android.os.AsyncTask +import android.text.Spanned +import android.text.TextUtils +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.view.ViewGroup.MarginLayoutParams +import android.view.ViewTreeObserver +import android.widget.ImageView +import android.widget.TextView +import androidx.core.text.BidiFormatter +import androidx.core.view.ViewCompat +import androidx.core.view.isVisible +import androidx.recyclerview.widget.RecyclerView +import org.wordpress.android.R +import org.wordpress.android.WordPress +import org.wordpress.android.datasets.NotificationsTable +import org.wordpress.android.models.Note +import org.wordpress.android.models.Note.NoteTimeGroup +import org.wordpress.android.models.Note.TimeStampComparator +import org.wordpress.android.ui.comments.CommentUtils +import org.wordpress.android.ui.notifications.NotificationsListFragmentPage.OnNoteClickListener +import org.wordpress.android.ui.notifications.adapters.NotesAdapter.NoteViewHolder +import org.wordpress.android.ui.notifications.blocks.NoteBlockClickableSpan +import org.wordpress.android.ui.notifications.utils.NotificationsUtilsWrapper +import org.wordpress.android.util.GravatarUtils +import org.wordpress.android.util.RtlUtils +import org.wordpress.android.util.image.ImageManager +import org.wordpress.android.util.image.ImageType +import javax.inject.Inject + +class NotesAdapter( + context: Context, dataLoadedListener: DataLoadedListener, + onLoadMoreListener: OnLoadMoreListener? +) : RecyclerView.Adapter() { + private val avatarSize: Int + private val textIndentSize: Int + private val dataLoadedListener: DataLoadedListener + private val onLoadMoreListener: OnLoadMoreListener? + private val notes = ArrayList() + private val filteredNotes = ArrayList() + + @JvmField + @Inject + var imageManager: ImageManager? = null + + @JvmField + @Inject + var notificationsUtilsWrapper: NotificationsUtilsWrapper? = null + + enum class FILTERS { + FILTER_ALL, + FILTER_COMMENT, + FILTER_FOLLOW, + FILTER_LIKE, + FILTER_UNREAD; + + override fun toString(): String { + return when (this) { + FILTER_ALL -> "all" + FILTER_COMMENT -> "comment" + FILTER_FOLLOW -> "follow" + FILTER_LIKE -> "like" + FILTER_UNREAD -> "unread" + } + } + } + + var currentFilter = FILTERS.FILTER_ALL + private set + private var reloadNotesFromDBTask: ReloadNotesFromDBTask? = null + + interface DataLoadedListener { + fun onDataLoaded(itemsCount: Int) + } + + interface OnLoadMoreListener { + fun onLoadMore(timestamp: Long) + } + + private var onNoteClickListener: OnNoteClickListener? = null + fun setFilter(newFilter: FILTERS) { + currentFilter = newFilter + } + + fun addAll(notes: List, clearBeforeAdding: Boolean) { + notes.sortedWith(TimeStampComparator()) + try { + if (clearBeforeAdding) { + this.notes.clear() + } + this.notes.addAll(notes) + } finally { + myNotifyDatasetChanged() + } + } + + @SuppressLint("NotifyDataSetChanged") + private fun myNotifyDatasetChanged() { + buildFilteredNotesList(filteredNotes, notes, currentFilter) + notifyDataSetChanged() + dataLoadedListener.onDataLoaded(itemCount) + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): NoteViewHolder { + val view = LayoutInflater.from(parent.context) + .inflate(R.layout.notifications_list_item, parent, false) + return NoteViewHolder(view) + } + + private fun getNoteAtPosition(position: Int): Note? { + return if (isValidPosition(position)) { + filteredNotes[position] + } else null + } + + private fun isValidPosition(position: Int): Boolean { + return position >= 0 && position < filteredNotes.size + } + + override fun getItemCount(): Int { + return filteredNotes.size + } + + @Suppress("CyclomaticComplexMethod", "LongMethod") + override fun onBindViewHolder(noteViewHolder: NoteViewHolder, position: Int) { + val note = getNoteAtPosition(position) ?: return + noteViewHolder.contentView.tag = note.id + + // Display group header + val timeGroup = Note.getTimeGroupForTimestamp(note.timestamp) + var previousTimeGroup: NoteTimeGroup? = null + if (position > 0) { + val previousNote = getNoteAtPosition(position - 1) + previousTimeGroup = Note.getTimeGroupForTimestamp( + previousNote!!.timestamp + ) + } + if (previousTimeGroup?.let { it == timeGroup } == true) { + noteViewHolder.headerText.visibility = View.GONE + } else { + noteViewHolder.headerText.visibility = View.VISIBLE + timeGroup?.let { + noteViewHolder.headerText.setText( + when (it) { + NoteTimeGroup.GROUP_TODAY -> R.string.stats_timeframe_today + NoteTimeGroup.GROUP_YESTERDAY -> R.string.stats_timeframe_yesterday + NoteTimeGroup.GROUP_OLDER_TWO_DAYS -> R.string.older_two_days + NoteTimeGroup.GROUP_OLDER_WEEK -> R.string.older_last_week + NoteTimeGroup.GROUP_OLDER_MONTH -> R.string.older_month + } + ) + } + } + + // Subject is stored in db as html to preserve text formatting + var noteSubjectSpanned: Spanned = note.getFormattedSubject(notificationsUtilsWrapper) + // Trim the '\n\n' added by HtmlCompat.fromHtml(...) + noteSubjectSpanned = noteSubjectSpanned.subSequence( + 0, + TextUtils.getTrimmedLength(noteSubjectSpanned) + ) as Spanned + val spans = noteSubjectSpanned.getSpans( + 0, + noteSubjectSpanned.length, + NoteBlockClickableSpan::class.java + ) + for (span in spans) { + span.enableColors(noteViewHolder.contentView.context) + } + noteViewHolder.textSubject.text = noteSubjectSpanned + val noteSubjectNoticon = note.commentSubjectNoticon + if (!TextUtils.isEmpty(noteSubjectNoticon)) { + val parent = noteViewHolder.textSubject.parent + // Fix position of the subject noticon in the RtL mode + if (parent is ViewGroup) { + val textDirection = if (BidiFormatter.getInstance() + .isRtl(noteViewHolder.textSubject.text) + ) ViewCompat.LAYOUT_DIRECTION_RTL else ViewCompat.LAYOUT_DIRECTION_LTR + ViewCompat.setLayoutDirection(parent, textDirection) + } + // mirror noticon in the rtl mode + if (RtlUtils.isRtl(noteViewHolder.itemView.context)) { + noteViewHolder.textSubjectNoticon.scaleX = -1f + } + CommentUtils.indentTextViewFirstLine(noteViewHolder.textSubject, textIndentSize) + noteViewHolder.textSubjectNoticon.text = noteSubjectNoticon + noteViewHolder.textSubjectNoticon.visibility = View.VISIBLE + } else { + noteViewHolder.textSubjectNoticon.visibility = View.GONE + } + val noteSnippet = note.commentSubject + if (!TextUtils.isEmpty(noteSnippet)) { + handleMaxLines(noteViewHolder.textSubject, noteViewHolder.textDetail) + noteViewHolder.textDetail.text = noteSnippet + noteViewHolder.textDetail.visibility = View.VISIBLE + } else { + noteViewHolder.textDetail.visibility = View.GONE + } + val avatarUrl = GravatarUtils.fixGravatarUrl(note.iconURL, avatarSize) + imageManager!!.loadIntoCircle( + noteViewHolder.imageAvatar, + ImageType.AVATAR_WITH_BACKGROUND, + avatarUrl + ) + noteViewHolder.unreadNotificationView.isVisible = note.isUnread + + // request to load more comments when we near the end + if (onLoadMoreListener != null && position >= itemCount - 1) { + onLoadMoreListener.onLoadMore(note.timestamp) + } + val headerMarginTop: Int + val context = noteViewHolder.itemView.context + headerMarginTop = if (position == 0) { + context.resources + .getDimensionPixelSize(R.dimen.notifications_header_margin_top_position_0) + } else { + context.resources + .getDimensionPixelSize(R.dimen.notifications_header_margin_top_position_n) + } + val layoutParams = noteViewHolder.headerText.layoutParams as MarginLayoutParams + layoutParams.topMargin = headerMarginTop + noteViewHolder.headerText.layoutParams = layoutParams + } + + private fun handleMaxLines(subject: TextView, detail: TextView) { + subject.viewTreeObserver.addOnPreDrawListener(object : ViewTreeObserver.OnPreDrawListener { + override fun onPreDraw(): Boolean { + subject.viewTreeObserver.removeOnPreDrawListener(this) + if (subject.lineCount == 2) { + detail.maxLines = 1 + } else { + detail.maxLines = 2 + } + return false + } + }) + } + + fun setOnNoteClickListener(mNoteClickListener: OnNoteClickListener?) { + onNoteClickListener = mNoteClickListener + } + + fun cancelReloadNotesTask() { + if (reloadNotesFromDBTask != null && reloadNotesFromDBTask!!.status != AsyncTask.Status.FINISHED) { + reloadNotesFromDBTask!!.cancel(true) + reloadNotesFromDBTask = null + } + } + + fun reloadNotesFromDBAsync() { + cancelReloadNotesTask() + reloadNotesFromDBTask = ReloadNotesFromDBTask() + reloadNotesFromDBTask!!.executeOnExecutor(AsyncTask.THREAD_POOL_EXECUTOR) + } + + @SuppressLint("StaticFieldLeak") + private inner class ReloadNotesFromDBTask : AsyncTask>() { + override fun doInBackground(vararg voids: Void?): ArrayList { + return NotificationsTable.getLatestNotes() + } + + override fun onPostExecute(notes: ArrayList) { + this@NotesAdapter.notes.clear() + this@NotesAdapter.notes.addAll(notes) + myNotifyDatasetChanged() + } + } + + inner class NoteViewHolder(view: View) : RecyclerView.ViewHolder(view) { + val contentView: View + val headerText: TextView + val textSubject: TextView + val textSubjectNoticon: TextView + val textDetail: TextView + val imageAvatar: ImageView + val unreadNotificationView: View + + init { + contentView = checkNotNull(view.findViewById(R.id.note_content_container)) + headerText = checkNotNull(view.findViewById(R.id.header_text)) + textSubject = checkNotNull(view.findViewById(R.id.note_subject)) + textSubjectNoticon = checkNotNull(view.findViewById(R.id.note_subject_noticon)) + textDetail = checkNotNull(view.findViewById(R.id.note_detail)) + imageAvatar = checkNotNull(view.findViewById(R.id.note_avatar)) + unreadNotificationView = checkNotNull(view.findViewById(R.id.notification_unread)) + contentView.setOnClickListener(onClickListener) + } + } + + private val onClickListener = View.OnClickListener { view -> + if (onNoteClickListener != null && view.tag is String) { + onNoteClickListener!!.onClickNote(view.tag as String) + } + } + + init { + (context.applicationContext as WordPress).component().inject(this) + this.dataLoadedListener = dataLoadedListener + this.onLoadMoreListener = onLoadMoreListener + + // this is on purpose - we don't show more than a hundred or so notifications at a time so no need to set + // stable IDs. This helps prevent crashes in case a note comes with no ID (we've code checking for that + // elsewhere, but telling the RecyclerView.Adapter the notes have stable Ids and then failing to provide them + // will make things go south as in https://github.com/wordpress-mobile/WordPress-Android/issues/8741 + setHasStableIds(false) + avatarSize = context.resources.getDimension(R.dimen.notifications_avatar_sz).toInt() + textIndentSize = + context.resources.getDimensionPixelSize(R.dimen.notifications_text_indent_sz) + } + + companion object { + // Instead of building the filtered notes list dynamically, create it once and re-use it. + // Otherwise it's re-created so many times during layout. + fun buildFilteredNotesList( + filteredNotes: ArrayList, + notes: ArrayList, + filter: FILTERS + ) { + filteredNotes.clear() + if (notes.isEmpty() || filter == FILTERS.FILTER_ALL) { + filteredNotes.addAll(notes) + return + } + for (currentNote in notes) { + when (filter) { + FILTERS.FILTER_COMMENT -> if (currentNote.isCommentType) { + filteredNotes.add(currentNote) + } + FILTERS.FILTER_FOLLOW -> if (currentNote.isFollowType) { + filteredNotes.add(currentNote) + } + FILTERS.FILTER_UNREAD -> if (currentNote.isUnread) { + filteredNotes.add(currentNote) + } + FILTERS.FILTER_LIKE -> if (currentNote.isLikeType) { + filteredNotes.add(currentNote) + } + else -> Unit + } + } + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/posts/PostsListActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/posts/PostsListActivity.kt index 007fa44e5b05..de058ca0ed9b 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/posts/PostsListActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/posts/PostsListActivity.kt @@ -21,9 +21,7 @@ import androidx.appcompat.widget.Toolbar import androidx.lifecycle.ViewModelProvider import androidx.viewpager.widget.ViewPager.OnPageChangeListener import com.google.android.material.snackbar.Snackbar -import com.google.android.play.core.review.ReviewException import com.google.android.play.core.review.ReviewManagerFactory -import com.google.android.play.core.review.model.ReviewErrorCode import org.wordpress.android.R import org.wordpress.android.WordPress import org.wordpress.android.databinding.PostListActivityBinding @@ -66,6 +64,7 @@ import org.wordpress.android.util.SnackbarItem import org.wordpress.android.util.SnackbarSequencer import org.wordpress.android.util.extensions.getSerializableCompat import org.wordpress.android.util.extensions.getSerializableExtraCompat +import org.wordpress.android.util.extensions.logException import org.wordpress.android.util.extensions.redirectContextClickToLongPressListener import org.wordpress.android.util.extensions.setLiftOnScrollTargetViewIdAndRequestLayout import org.wordpress.android.viewmodel.observeEvent @@ -360,12 +359,7 @@ class PostsListActivity : LocaleAwareActivity(), AppLog.e(AppLog.T.POSTS, "Error launching google review API flow.", e) } } else { - @ReviewErrorCode val reviewErrorCode = (task.exception as ReviewException).errorCode - AppLog.e( - AppLog.T.POSTS, - "Error fetching ReviewInfo object from Review API to start in-app review process", - reviewErrorCode - ) + task.logException() } } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/qrcodeauth/QRCodeAuthFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/qrcodeauth/QRCodeAuthFragment.kt index d006711d1277..1b11cf2732da 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/qrcodeauth/QRCodeAuthFragment.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/qrcodeauth/QRCodeAuthFragment.kt @@ -13,15 +13,21 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.ComposeView import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentResultListener +import androidx.fragment.app.FragmentTransaction import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels import androidx.lifecycle.lifecycleScope import androidx.lifecycle.viewmodel.compose.viewModel -import com.google.mlkit.vision.codescanner.GmsBarcodeScanning import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import org.wordpress.android.R import org.wordpress.android.analytics.AnalyticsTracker.Stat +import org.wordpress.android.ui.barcodescanner.BarcodeScanningFragment +import org.wordpress.android.ui.barcodescanner.BarcodeScanningFragment.Companion.KEY_BARCODE_SCANNING_REQUEST +import org.wordpress.android.ui.barcodescanner.BarcodeScanningFragment.Companion.KEY_BARCODE_SCANNING_SCAN_STATUS +import org.wordpress.android.ui.barcodescanner.CodeScannerStatus import org.wordpress.android.ui.compose.components.VerticalScrollBox import org.wordpress.android.ui.compose.theme.AppTheme import org.wordpress.android.ui.posts.BasicDialogViewModel @@ -49,6 +55,14 @@ class QRCodeAuthFragment : Fragment() { private val qrCodeAuthViewModel: QRCodeAuthViewModel by viewModels() private val dialogViewModel: BasicDialogViewModel by activityViewModels() + @Suppress("DEPRECATION") + private val resultListener = FragmentResultListener { requestKey, result -> + if (requestKey == KEY_BARCODE_SCANNING_REQUEST) { + val resultValue = result.getParcelable(KEY_BARCODE_SCANNING_SCAN_STATUS) + resultValue?.let { qrCodeAuthViewModel.handleScanningResult(it) } + } + } + override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, @@ -64,6 +78,7 @@ class QRCodeAuthFragment : Fragment() { super.onViewCreated(view, savedInstanceState) initBackPressHandler() initViewModel(savedInstanceState) + initScannerResultListener() observeViewModel() } @@ -80,6 +95,14 @@ class QRCodeAuthFragment : Fragment() { qrCodeAuthViewModel.start(uri, isDeepLink, savedInstanceState) } + private fun initScannerResultListener() { + requireActivity().supportFragmentManager.setFragmentResultListener( + KEY_BARCODE_SCANNING_REQUEST, + viewLifecycleOwner, + resultListener + ) + } + private fun handleActionEvents(actionEvent: QRCodeAuthActionEvent) { when (actionEvent) { is LaunchDismissDialog -> launchDismissDialog(actionEvent.dialogModel) @@ -87,6 +110,7 @@ class QRCodeAuthFragment : Fragment() { is FinishActivity -> requireActivity().finish() } } + private fun launchDismissDialog(model: QRCodeAuthDialogModel) { dialogViewModel.showDialog( requireActivity().supportFragmentManager, @@ -96,17 +120,22 @@ class QRCodeAuthFragment : Fragment() { getString(model.message), getString(model.positiveButtonLabel), model.negativeButtonLabel?.let { label -> getString(label) }, - model.cancelButtonLabel?.let { label -> getString(label) } + model.cancelButtonLabel?.let { label -> getString(label) }, + false ) ) } private fun launchScanner() { qrCodeAuthViewModel.track(Stat.QRLOGIN_SCANNER_DISPLAYED) - val scanner = GmsBarcodeScanning.getClient(requireContext()) - scanner.startScan() - .addOnSuccessListener { barcode -> qrCodeAuthViewModel.onScanSuccess(barcode.rawValue) } - .addOnFailureListener { qrCodeAuthViewModel.onScanFailure() } + replaceFragment(BarcodeScanningFragment()) + } + + private fun replaceFragment(fragment: Fragment) { + val transaction: FragmentTransaction = requireActivity().supportFragmentManager.beginTransaction() + transaction.replace(R.id.fragment_container, fragment) + transaction.addToBackStack(null) + transaction.commit() } private fun initBackPressHandler() { @@ -114,6 +143,7 @@ class QRCodeAuthFragment : Fragment() { qrCodeAuthViewModel.onBackPressed() } } + override fun onSaveInstanceState(outState: Bundle) { qrCodeAuthViewModel.writeToBundle(outState) super.onSaveInstanceState(outState) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/qrcodeauth/QRCodeAuthViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/qrcodeauth/QRCodeAuthViewModel.kt index c17c5f267e16..e0a541dfbe77 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/qrcodeauth/QRCodeAuthViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/qrcodeauth/QRCodeAuthViewModel.kt @@ -23,6 +23,9 @@ import org.wordpress.android.fluxc.network.rest.wpcom.qrcodeauth.QRCodeAuthError import org.wordpress.android.fluxc.store.qrcodeauth.QRCodeAuthStore import org.wordpress.android.fluxc.store.qrcodeauth.QRCodeAuthStore.QRCodeAuthResult import org.wordpress.android.fluxc.store.qrcodeauth.QRCodeAuthStore.QRCodeAuthValidateResult +import org.wordpress.android.ui.barcodescanner.BarcodeScanningTracker +import org.wordpress.android.ui.barcodescanner.CodeScannerStatus +import org.wordpress.android.ui.barcodescanner.ScanningSource import org.wordpress.android.ui.posts.BasicDialogViewModel.DialogInteraction import org.wordpress.android.ui.posts.BasicDialogViewModel.DialogInteraction.Dismissed import org.wordpress.android.ui.posts.BasicDialogViewModel.DialogInteraction.Negative @@ -52,12 +55,15 @@ class QRCodeAuthViewModel @Inject constructor( private val uiStateMapper: QRCodeAuthUiStateMapper, private val networkUtilsWrapper: NetworkUtilsWrapper, private val validator: QRCodeAuthValidator, - private val analyticsTrackerWrapper: AnalyticsTrackerWrapper + private val analyticsTrackerWrapper: AnalyticsTrackerWrapper, + private val barcodeScanningTracker: BarcodeScanningTracker ) : ViewModel() { private val _actionEvents = Channel(Channel.BUFFERED) val actionEvents = _actionEvents.receiveAsFlow() + private val _uiState = MutableStateFlow(Loading) val uiState: StateFlow = _uiState + private var trackingOrigin: String? = null private var data: String? = null private var token: String? = null @@ -65,6 +71,7 @@ class QRCodeAuthViewModel @Inject constructor( private var browser: String? = null private var lastState: QRCodeAuthUiStateType? = null private var isStarted = false + fun start(uri: String? = null, isDeepLink: Boolean = false, savedInstanceState: Bundle? = null) { if (isStarted) return isStarted = true @@ -100,7 +107,6 @@ class QRCodeAuthViewModel @Inject constructor( this::onAuthenticateCancelClicked ) ) - AUTHENTICATING -> postUiState(uiStateMapper.mapToAuthenticating(location = location, browser = browser)) DONE -> postUiState(uiStateMapper.mapToDone(this::onDismissClicked)) // errors @@ -111,25 +117,36 @@ class QRCodeAuthViewModel @Inject constructor( this::onCancelClicked ) ) - EXPIRED_TOKEN -> postUiState(uiStateMapper.mapToExpired(this::onScanAgainClicked, this::onCancelClicked)) NO_INTERNET -> { postUiState(uiStateMapper.mapToNoInternet(this::onScanAgainClicked, this::onCancelClicked)) } - else -> updateUiStateAndLaunchScanner() } } + fun handleScanningResult(status: CodeScannerStatus) { + when (status) { + is CodeScannerStatus.Success -> onScanSuccess(status.code) + is CodeScannerStatus.Failure -> onScanFailure(status) + is CodeScannerStatus.Exit -> onExit() + is CodeScannerStatus.NavigateUp -> onBackPressed() + } + } // https://apps.wordpress.com/get/?campaign=login-qr-code#qr-code-login?token=asdfadsfa&data=asdfasdf fun onScanSuccess(scannedValue: String?) { + barcodeScanningTracker.trackSuccess(ScanningSource.QRCODE_LOGIN) track(Stat.QRLOGIN_SCANNER_SCANNED_CODE) process(scannedValue) } - fun onScanFailure() { - // Note: This is a result of the tap on "X" within the scanner view - track(Stat.QRLOGIN_SCANNER_DISMISSED) + fun onScanFailure(status: CodeScannerStatus.Failure) { + barcodeScanningTracker.trackScanFailure(ScanningSource.QRCODE_LOGIN, status.type) + postActionEvent(FinishActivity) + } + + private fun onExit() { + track(Stat.QRLOGIN_SCANNER_DISMISSED_CAMERA_PERMISSION_DENIED) postActionEvent(FinishActivity) } @@ -282,8 +299,11 @@ class QRCodeAuthViewModel @Inject constructor( fun onDialogInteraction(interaction: DialogInteraction) { when (interaction) { - is Positive -> postActionEvent(FinishActivity) - is Negative -> Unit + is Positive -> { + track(Stat.QRLOGIN_SCANNER_DISMISSED) + postActionEvent(FinishActivity) + } + is Negative -> onScanAgainClicked() is Dismissed -> Unit } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/quickstart/QuickStartMySitePrompts.kt b/WordPress/src/main/java/org/wordpress/android/ui/quickstart/QuickStartMySitePrompts.kt index 5861be66f0f4..4d200bdbfb34 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/quickstart/QuickStartMySitePrompts.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/quickstart/QuickStartMySitePrompts.kt @@ -50,7 +50,7 @@ enum class QuickStartMySitePrompts constructor( R.id.root_view_main, R.id.bottom_nav_reader_button, R.string.quick_start_dialog_follow_sites_message_short_reader, - R.drawable.ic_reader_white_24dp + R.drawable.ic_reader_selected ), UPLOAD_SITE_ICON( QuickStartStore.QUICK_START_UPLOAD_SITE_ICON_LABEL, diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderActivityLauncher.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderActivityLauncher.java index 81843376bae2..1299d325b157 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderActivityLauncher.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderActivityLauncher.java @@ -312,9 +312,13 @@ public static void showReaderSubs(Context context) { } public static void showReaderSubs(Context context, int selectPosition) { - Intent intent = new Intent(context, ReaderSubsActivity.class); + context.startActivity(createIntentShowReaderSubs(context, selectPosition)); + } + + public static Intent createIntentShowReaderSubs(@NonNull final Context context, final int selectPosition) { + final Intent intent = new Intent(context, ReaderSubsActivity.class); intent.putExtra(ReaderConstants.ARG_SUBS_TAB_POSITION, selectPosition); - context.startActivity(intent); + return intent; } public static void showReaderInterests(Activity activity) { diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderBlogFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderBlogFragment.java index 696b3e44118f..33ea702b6a9c 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderBlogFragment.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderBlogFragment.java @@ -7,7 +7,6 @@ import android.view.MenuInflater; import android.view.MenuItem; import android.view.View; -import android.view.View.OnClickListener; import android.view.ViewGroup; import androidx.annotation.NonNull; @@ -17,15 +16,11 @@ import org.wordpress.android.R; import org.wordpress.android.WordPress; -import org.wordpress.android.datasets.ReaderTagTable; import org.wordpress.android.models.ReaderBlog; -import org.wordpress.android.models.ReaderTag; import org.wordpress.android.ui.ActionableEmptyView; -import org.wordpress.android.ui.prefs.AppPrefs; import org.wordpress.android.ui.reader.adapters.ReaderBlogAdapter; import org.wordpress.android.ui.reader.adapters.ReaderBlogAdapter.ReaderBlogType; import org.wordpress.android.ui.reader.tracker.ReaderTracker; -import org.wordpress.android.ui.reader.utils.ReaderUtils; import org.wordpress.android.ui.reader.views.ReaderRecyclerView; import org.wordpress.android.util.AppLog; @@ -97,39 +92,20 @@ private void checkEmptyView() { if (hasBlogAdapter() && getBlogAdapter().isEmpty()) { actionableEmptyView.setVisibility(View.VISIBLE); - actionableEmptyView.image.setImageResource(R.drawable.img_illustration_following_empty_results_196dp); - actionableEmptyView.subtitle.setText(R.string.reader_empty_followed_blogs_description); - actionableEmptyView.button.setText(R.string.reader_empty_followed_blogs_button_discover); - actionableEmptyView.button.setOnClickListener(new OnClickListener() { - @Override public void onClick(View view) { - ReaderTag tag = ReaderUtils.getTagFromEndpoint(ReaderTag.DISCOVER_PATH); - - if (!ReaderTagTable.tagExists(tag)) { - tag = ReaderTagTable.getFirstTag(); - } - - AppPrefs.setReaderTag(tag); - - if (getActivity() != null) { - getActivity().finish(); - } - } - }); + actionableEmptyView.subtitle.setText(R.string.reader_no_followed_blogs_description_subs); + actionableEmptyView.image.setVisibility(View.GONE); + actionableEmptyView.button.setVisibility(View.GONE); switch (getBlogType()) { case FOLLOWED: if (getBlogAdapter().hasSearchFilter()) { actionableEmptyView.updateLayoutForSearch(true, 0); - actionableEmptyView.title.setText(R.string.reader_empty_followed_blogs_search_title); + actionableEmptyView.title.setText(R.string.reader_no_followed_blogs_search_title); actionableEmptyView.subtitle.setVisibility(View.GONE); - actionableEmptyView.button.setVisibility(View.GONE); - actionableEmptyView.image.setVisibility(View.GONE); } else { actionableEmptyView.updateLayoutForSearch(false, 0); - actionableEmptyView.title.setText(R.string.reader_empty_followed_blogs_title); + actionableEmptyView.title.setText(R.string.reader_no_followed_blogs_title); actionableEmptyView.subtitle.setVisibility(View.VISIBLE); - actionableEmptyView.button.setVisibility(View.VISIBLE); - actionableEmptyView.image.setVisibility(View.VISIBLE); } break; } @@ -182,7 +158,7 @@ public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflat MenuItem searchMenu = menu.findItem(R.id.menu_search); SearchView searchView = (SearchView) searchMenu.getActionView(); searchView.setMaxWidth(Integer.MAX_VALUE); - searchView.setQueryHint(getString(R.string.reader_hint_search_followed_sites)); + searchView.setQueryHint(getString(R.string.reader_hint_search_subscribed_blogs)); searchMenu.setOnActionExpandListener(new MenuItem.OnActionExpandListener() { @Override @@ -251,11 +227,8 @@ private ReaderBlogAdapter getBlogAdapter() { ReaderTracker.SOURCE_SETTINGS ); mAdapter.setBlogClickListener(this); - mAdapter.setDataLoadedListener(new ReaderInterfaces.DataLoadedListener() { - @Override - public void onDataLoaded(boolean isEmpty) { - checkEmptyView(); - } + mAdapter.setDataLoadedListener(isEmpty -> { + checkEmptyView(); }); } return mAdapter; diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderConstants.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderConstants.java index 2149958befd3..c0798c5824ca 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderConstants.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderConstants.java @@ -2,7 +2,7 @@ public class ReaderConstants { // max # posts to request when updating posts - public static final int READER_MAX_POSTS_TO_REQUEST = 20; + public static final int READER_MAX_POSTS_TO_REQUEST = 7; // max # results to request when searching posts & sites public static final int READER_MAX_SEARCH_RESULTS_TO_REQUEST = 20; @@ -55,6 +55,7 @@ public class ReaderConstants { static final String ARG_VIDEO_URL = "video_url"; static final String ARG_IS_TOP_LEVEL = "is_top_level"; static final String ARG_SUBS_TAB_POSITION = "subs_tab_position"; + static final String ARG_IS_FILTERABLE = "is_filterable"; static final String KEY_POST_SLUGS_RESOLUTION_UNDERWAY = "post_slugs_resolution_underway"; static final String KEY_ALREADY_UPDATED = "already_updated"; @@ -66,7 +67,7 @@ public class ReaderConstants { static final String KEY_FIRST_LOAD = "first_load"; static final String KEY_ACTIVITY_TITLE = "activity_title"; static final String KEY_TRACKED_POSITIONS = "tracked_positions"; - static final String KEY_IS_REFRESHING = "is_refreshing"; + static final String KEY_CURRENT_UPDATE_ACTIONS = "current_update_actions"; static final String KEY_ACTIVE_SEARCH_TAB = "active_search_tab"; static final String KEY_SITE_ID = "site_id"; diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderFragment.kt index b6a0fef6063d..28397a28fb70 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderFragment.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderFragment.kt @@ -1,22 +1,14 @@ package org.wordpress.android.ui.reader import android.os.Bundle -import android.view.LayoutInflater -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem import android.view.View -import android.widget.TextView -import androidx.appcompat.app.AppCompatActivity -import androidx.core.view.MenuProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.view.isVisible import androidx.fragment.app.Fragment import androidx.lifecycle.ViewModelProvider import androidx.recyclerview.widget.RecyclerView -import androidx.viewpager2.adapter.FragmentStateAdapter -import androidx.viewpager2.widget.ViewPager2 -import com.google.android.material.tabs.TabLayout -import com.google.android.material.tabs.TabLayoutMediator import dagger.hilt.android.AndroidEntryPoint import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe @@ -24,23 +16,25 @@ import org.greenrobot.eventbus.ThreadMode.MAIN import org.wordpress.android.R import org.wordpress.android.databinding.ReaderFragmentLayoutBinding import org.wordpress.android.models.JetpackPoweredScreen -import org.wordpress.android.models.ReaderTagList import org.wordpress.android.ui.ScrollableViewInitializedListener +import org.wordpress.android.ui.compose.theme.AppTheme import org.wordpress.android.ui.jetpackoverlay.JetpackFeatureFullScreenOverlayFragment import org.wordpress.android.ui.jetpackoverlay.JetpackFeatureRemovalOverlayUtil.JetpackFeatureOverlayScreenType import org.wordpress.android.ui.main.WPMainNavigationView.PageType.READER import org.wordpress.android.ui.mysite.jetpackbadge.JetpackPoweredBottomSheetFragment import org.wordpress.android.ui.pages.SnackbarMessageHolder import org.wordpress.android.ui.quickstart.QuickStartEvent -import org.wordpress.android.ui.reader.ReaderTypes.ReaderPostListType import org.wordpress.android.ui.reader.discover.ReaderDiscoverFragment import org.wordpress.android.ui.reader.discover.interests.ReaderInterestsFragment import org.wordpress.android.ui.reader.services.update.ReaderUpdateLogic.UpdateTask.FOLLOWED_BLOGS import org.wordpress.android.ui.reader.services.update.ReaderUpdateLogic.UpdateTask.TAGS import org.wordpress.android.ui.reader.services.update.ReaderUpdateServiceStarter +import org.wordpress.android.ui.reader.subfilter.SubFilterViewModel +import org.wordpress.android.ui.reader.subfilter.SubfilterCategory import org.wordpress.android.ui.reader.viewmodels.ReaderViewModel import org.wordpress.android.ui.reader.viewmodels.ReaderViewModel.ReaderUiState.ContentUiState -import org.wordpress.android.ui.reader.viewmodels.ReaderViewModel.ReaderUiState.ContentUiState.TabUiState +import org.wordpress.android.ui.reader.views.compose.ReaderTopAppBar +import org.wordpress.android.ui.reader.views.compose.filter.ReaderFilterType import org.wordpress.android.ui.utils.UiHelpers import org.wordpress.android.ui.utils.UiString.UiStringText import org.wordpress.android.util.JetpackBrandingUtils @@ -50,12 +44,11 @@ import org.wordpress.android.util.SnackbarItem.Action import org.wordpress.android.util.SnackbarItem.Info import org.wordpress.android.util.SnackbarSequencer import org.wordpress.android.viewmodel.observeEvent -import org.wordpress.android.widgets.QuickStartFocusPoint import java.util.EnumSet import javax.inject.Inject @AndroidEntryPoint -class ReaderFragment : Fragment(R.layout.reader_fragment_layout), MenuProvider, ScrollableViewInitializedListener { +class ReaderFragment : Fragment(R.layout.reader_fragment_layout), ScrollableViewInitializedListener { @Inject lateinit var viewModelFactory: ViewModelProvider.Factory @@ -72,38 +65,17 @@ class ReaderFragment : Fragment(R.layout.reader_fragment_layout), MenuProvider, lateinit var snackbarSequencer: SnackbarSequencer private lateinit var viewModel: ReaderViewModel - private var searchMenuItem: MenuItem? = null - private var settingsMenuItem: MenuItem? = null - private var settingsMenuItemFocusPoint: QuickStartFocusPoint? = null - private var binding: ReaderFragmentLayoutBinding? = null - private val viewPagerCallback = object : ViewPager2.OnPageChangeCallback() { - override fun onPageSelected(position: Int) { - super.onPageSelected(position) - viewModel.uiState.value?.let { - if (it is ContentUiState) { - val selectedTag = it.readerTagList[position] - viewModel.onTagChanged(selectedTag) - } - } - } - } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - requireActivity().addMenuProvider(this, viewLifecycleOwner) binding = ReaderFragmentLayoutBinding.bind(view).apply { - initToolbar() - initViewPager() + initTopAppBar() initViewModel(savedInstanceState) } } override fun onDestroyView() { super.onDestroyView() - searchMenuItem = null - settingsMenuItem = null - settingsMenuItemFocusPoint = null binding = null } @@ -117,80 +89,45 @@ class ReaderFragment : Fragment(R.layout.reader_fragment_layout), MenuProvider, activity?.let { viewModel.onScreenInBackground(it.isChangingConfigurations) } } - override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { - menuInflater.inflate(R.menu.reader_home, menu) - menu.findItem(R.id.menu_search).apply { - searchMenuItem = this - this.isVisible = viewModel.uiState.value?.searchMenuItemUiState?.isVisible ?: false - } - menu.findItem(R.id.menu_settings).apply { - settingsMenuItem = this - settingsMenuItemFocusPoint = this.actionView?.findViewById(R.id.menu_quick_start_focus_point) - this.isVisible = viewModel.uiState.value?.settingsMenuItemUiState?.isVisible ?: false - settingsMenuItemFocusPoint?.isVisible = - viewModel.uiState.value?.settingsMenuItemUiState?.showQuickStartFocusPoint ?: false - this.actionView?.setOnClickListener { viewModel.onSettingsActionClicked() } - } - } - - override fun onMenuItemSelected(menuItem: MenuItem) = when (menuItem.itemId) { - R.id.menu_search -> { - viewModel.onSearchActionClicked() - true - } - R.id.menu_settings -> { - viewModel.onSettingsActionClicked() - true + private fun ReaderFragmentLayoutBinding.initTopAppBar() { + readerTopBarComposeView.apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + val topAppBarState by viewModel.topBarUiState.observeAsState() + val state = topAppBarState ?: return@setContent + + AppTheme { + ReaderTopAppBar( + topBarUiState = state, + onMenuItemClick = viewModel::onTopBarMenuItemClick, + onFilterClick = ::tryOpenFilterList, + onClearFilterClick = ::clearFilter, + isSearchVisible = state.isSearchActionVisible, + onSearchClick = viewModel::onSearchActionClicked, + ) + } + } } - else -> false - } - - private fun ReaderFragmentLayoutBinding.initToolbar() { - toolbar.title = getString(R.string.reader_screen_title) - (requireActivity() as AppCompatActivity).setSupportActionBar(toolbar) - } - - private fun ReaderFragmentLayoutBinding.initViewPager() { - viewPager.registerOnPageChangeCallback(viewPagerCallback) } private fun ReaderFragmentLayoutBinding.initViewModel(savedInstanceState: Bundle?) { viewModel = ViewModelProvider(this@ReaderFragment, viewModelFactory).get(ReaderViewModel::class.java) - startObserving(savedInstanceState) + startReaderViewModel(savedInstanceState) } - private fun ReaderFragmentLayoutBinding.startObserving(savedInstanceState: Bundle?) { + private fun ReaderFragmentLayoutBinding.startReaderViewModel(savedInstanceState: Bundle?) { viewModel.uiState.observe(viewLifecycleOwner) { uiState -> - uiState?.let { - when (it) { - is ContentUiState -> { - updateTabs(it) - } - } - uiHelpers.updateVisibility(tabLayout, uiState.tabLayoutVisible) - searchMenuItem?.isVisible = uiState.searchMenuItemUiState.isVisible - settingsMenuItem?.isVisible = uiState.settingsMenuItemUiState.isVisible - settingsMenuItemFocusPoint?.isVisible = - viewModel.uiState.value?.settingsMenuItemUiState?.showQuickStartFocusPoint ?: false - } + uiState?.let { updateUiState(it) } } viewModel.updateTags.observeEvent(viewLifecycleOwner) { ReaderUpdateServiceStarter.startService(context, EnumSet.of(TAGS, FOLLOWED_BLOGS)) } - viewModel.selectTab.observeEvent(viewLifecycleOwner) { navTarget -> - viewPager.setCurrentItem(navTarget.position, navTarget.smoothAnimation) - } - viewModel.showSearch.observeEvent(viewLifecycleOwner) { ReaderActivityLauncher.showReaderSearch(context) } - viewModel.showSettings.observeEvent(viewLifecycleOwner) { - ReaderActivityLauncher.showReaderSubs(context) - } - viewModel.showReaderInterests.observeEvent(viewLifecycleOwner) { showReaderInterests() } @@ -226,7 +163,49 @@ class ReaderFragment : Fragment(R.layout.reader_fragment_layout), MenuProvider, observeJetpackOverlayEvent(savedInstanceState) - viewModel.start() + viewModel.start(savedInstanceState) + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + viewModel.onSaveInstanceState(outState) + } + + private fun updateUiState(uiState: ReaderViewModel.ReaderUiState) { + when (uiState) { + is ContentUiState -> { + binding?.readerTopBarComposeView?.isVisible = true + initContentContainer(uiState) + } + } + } + + private fun initContentContainer(uiState: ContentUiState) { + // only initialize the fragment if there's one selected and it's not already initialized + val currentFragmentTag = childFragmentManager.findFragmentById(R.id.container)?.tag + if (uiState.selectedReaderTag == null || uiState.selectedReaderTag.tagSlug == currentFragmentTag) { + return + } + + childFragmentManager.beginTransaction().apply { + val fragment = if (uiState.selectedReaderTag.isDiscover) { + ReaderDiscoverFragment() + } else { + ReaderPostListFragment.newInstanceForTag( + uiState.selectedReaderTag, + ReaderTypes.ReaderPostListType.TAG_FOLLOWED, + true, + uiState.selectedReaderTag.isFilterable + ) + } + replace(R.id.container, fragment, uiState.selectedReaderTag.tagSlug) + commit() + } + viewModel.uiState.value?.let { + if (it is ContentUiState) { + viewModel.onTagChanged(uiState.selectedReaderTag) + } + } } private fun observeJetpackOverlayEvent(savedInstanceState: Bundle?) { @@ -258,64 +237,10 @@ class ReaderFragment : Fragment(R.layout.reader_fragment_layout), MenuProvider, ) } - private fun ReaderFragmentLayoutBinding.updateTabs(uiState: ContentUiState) { - if (viewPager.adapter == null || uiState.shouldUpdateViewPager) { - updateViewPagerAdapterAndMediator(uiState) - } - uiState.tabUiStates.forEachIndexed { index, tabUiState -> - val tab = tabLayout.getTabAt(index) as TabLayout.Tab - updateTab(tab, tabUiState) - } - } - - private fun ReaderFragmentLayoutBinding.updateTab(tab: TabLayout.Tab, tabUiState: TabUiState) { - val customView = tab.customView ?: createTabCustomView(tab) - with(customView) { - val title = findViewById(R.id.tab_label) - title.text = uiHelpers.getTextOfUiString(requireContext(), tabUiState.label) - } - } - - private fun ReaderFragmentLayoutBinding.updateViewPagerAdapterAndMediator(uiState: ContentUiState) { - viewPager.adapter = TabsAdapter(this@ReaderFragment, uiState.readerTagList) - TabLayoutMediator(tabLayout, viewPager, ReaderTabConfigurationStrategy(uiState)).attach() - } - - private inner class ReaderTabConfigurationStrategy( - private val uiState: ContentUiState - ) : TabLayoutMediator.TabConfigurationStrategy { - override fun onConfigureTab(tab: TabLayout.Tab, position: Int) { - binding?.updateTab(tab, uiState.tabUiStates[position]) - } - } - - private fun ReaderFragmentLayoutBinding.createTabCustomView(tab: TabLayout.Tab): View { - val customView = LayoutInflater.from(context) - .inflate(R.layout.tab_custom_view, tabLayout, false) - tab.customView = customView - return customView - } - fun requestBookmarkTab() { viewModel.bookmarkTabRequested() } - private class TabsAdapter(parent: Fragment, private val tags: ReaderTagList) : FragmentStateAdapter(parent) { - override fun getItemCount(): Int = tags.size - - override fun createFragment(position: Int): Fragment { - return if (tags[position].isDiscover) { - ReaderDiscoverFragment() - } else { - ReaderPostListFragment.newInstanceForTag( - tags[position], - ReaderPostListType.TAG_FOLLOWED, - true - ) - } - } - } - private fun showReaderInterests() { val readerInterestsFragment = childFragmentManager.findFragmentByTag(ReaderInterestsFragment.TAG) if (readerInterestsFragment == null) { @@ -384,4 +309,37 @@ class ReaderFragment : Fragment(R.layout.reader_fragment_layout), MenuProvider, viewModel.onQuickStartEventReceived(event) EventBus.getDefault().removeStickyEvent(event) } + + private fun getCurrentFeedFragment(): Fragment? { + return childFragmentManager.findFragmentById(R.id.container) + } + + // The view model is started by the ReaderPostListFragment for feeds that support filtering + private fun getSubFilterViewModel(): SubFilterViewModel? { + val currentFragment = getCurrentFeedFragment() + val selectedTag = (viewModel.uiState.value as? ContentUiState)?.selectedReaderTag + + if (currentFragment == null || selectedTag == null) return null + + return ViewModelProvider(currentFragment, viewModelFactory).get( + SubFilterViewModel.getViewModelKeyForTag(selectedTag), + SubFilterViewModel::class.java + ) + } + + private fun tryOpenFilterList(type: ReaderFilterType) { + val viewModel = getSubFilterViewModel() ?: return + + val category = when (type) { + ReaderFilterType.BLOG -> SubfilterCategory.SITES + ReaderFilterType.TAG -> SubfilterCategory.TAGS + } + + viewModel.onSubFiltersListButtonClicked(category) + } + + private fun clearFilter() { + val viewModel = getSubFilterViewModel() ?: return + viewModel.setDefaultSubfilter(isClearingFilter = true) + } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostDetailFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostDetailFragment.kt index 5c48b3900cfc..53bf0c38a6c6 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostDetailFragment.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostDetailFragment.kt @@ -4,6 +4,7 @@ import android.annotation.SuppressLint import android.app.Activity import android.app.DownloadManager import android.content.Context +import android.content.ContextWrapper import android.content.Intent import android.content.IntentFilter import android.content.pm.PackageManager @@ -11,6 +12,7 @@ import android.content.res.Resources import android.graphics.Rect import android.graphics.drawable.Drawable import android.net.Uri +import android.os.Build import android.os.Bundle import android.os.Parcelable import android.view.Gravity @@ -1160,10 +1162,18 @@ class ReaderPostDetailFragment : ViewPagerFragment(), super.onStart() dispatcher.register(this) EventBus.getDefault().register(this) - activity?.registerReceiver( - readerFileDownloadManager, - IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE) - ) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + activity?.registerReceiver( + readerFileDownloadManager, + IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE), + ContextWrapper.RECEIVER_NOT_EXPORTED + ) + } else { + activity?.registerReceiver( + readerFileDownloadManager, + IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE) + ) + } } override fun onStop() { diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostListActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostListActivity.java index 7b79445fc7f6..e408b0b92e88 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostListActivity.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostListActivity.java @@ -104,7 +104,7 @@ public void handleOnBackPressed() { toolbar.setNavigationOnClickListener(view -> finish()); if (getPostListType() == ReaderPostListType.BLOG_PREVIEW) { - setTitle(R.string.reader_title_blog_preview); + setTitle(R.string.reader_activity_title_blog_preview); if (savedInstanceState == null) { long blogId = getIntent().getLongExtra(ReaderConstants.ARG_BLOG_ID, 0); long feedId = getIntent().getLongExtra(ReaderConstants.ARG_FEED_ID, 0); @@ -119,7 +119,7 @@ public void handleOnBackPressed() { mSiteId = savedInstanceState.getLong(ReaderConstants.KEY_SITE_ID); } } else if (getPostListType() == ReaderPostListType.TAG_PREVIEW) { - setTitle(R.string.reader_title_tag_preview); + setTitle(R.string.reader_activity_title_tag_preview); ReaderTag tag = (ReaderTag) getIntent().getSerializableExtra(ReaderConstants.ARG_TAG); if (tag != null && savedInstanceState == null) { showListFragmentForTag(tag, mPostListType); @@ -290,7 +290,7 @@ private void showListFragmentForFeed(long feedId) { String title = ReaderBlogTable.getFeedName(feedId); if (title.isEmpty()) { - title = getString(R.string.reader_title_blog_preview); + title = getString(R.string.reader_activity_title_blog_preview); } setTitle(title); } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostListFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostListFragment.java index 61c49c1d81af..ec3e175ad5bb 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostListFragment.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderPostListFragment.java @@ -20,6 +20,8 @@ import android.widget.ProgressBar; import android.widget.TextView; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.appcompat.app.AlertDialog; @@ -31,7 +33,6 @@ import androidx.recyclerview.widget.RecyclerView; import com.google.android.material.dialog.MaterialAlertDialogBuilder; -import com.google.android.material.elevation.ElevationOverlayProvider; import com.google.android.material.snackbar.Snackbar; import com.google.android.material.tabs.TabLayout; import com.google.android.material.tabs.TabLayout.OnTabSelectedListener; @@ -62,6 +63,7 @@ import org.wordpress.android.fluxc.store.ReaderStore.OnReaderSitesSearched; import org.wordpress.android.fluxc.store.ReaderStore.ReaderSearchSitesPayload; import org.wordpress.android.models.FilterCriteria; +import org.wordpress.android.models.JetpackPoweredScreen; import org.wordpress.android.models.ReaderBlog; import org.wordpress.android.models.ReaderPost; import org.wordpress.android.models.ReaderPostDiscoverData; @@ -77,8 +79,6 @@ import org.wordpress.android.ui.main.BottomNavController; import org.wordpress.android.ui.main.SitePickerActivity; import org.wordpress.android.ui.main.WPMainActivity; -import org.wordpress.android.ui.main.WPMainNavigationView; -import org.wordpress.android.ui.main.WPMainNavigationView.PageType; import org.wordpress.android.ui.mysite.SelectedSiteRepository; import org.wordpress.android.ui.mysite.cards.quickstart.QuickStartRepository; import org.wordpress.android.ui.mysite.jetpackbadge.JetpackPoweredBottomSheetFragment; @@ -107,7 +107,10 @@ import org.wordpress.android.ui.reader.services.update.ReaderUpdateLogic.UpdateTask; import org.wordpress.android.ui.reader.services.update.ReaderUpdateServiceStarter; import org.wordpress.android.ui.reader.services.update.TagUpdateClientUtilsProvider; +import org.wordpress.android.ui.reader.subfilter.ActionType.OpenLoginPage; +import org.wordpress.android.ui.reader.subfilter.ActionType.OpenSearchPage; import org.wordpress.android.ui.reader.subfilter.ActionType.OpenSubsAtPage; +import org.wordpress.android.ui.reader.subfilter.ActionType.OpenSuggestedTagsPage; import org.wordpress.android.ui.reader.subfilter.BottomSheetUiState.BottomSheetVisible; import org.wordpress.android.ui.reader.subfilter.SubFilterViewModel; import org.wordpress.android.ui.reader.subfilter.SubfilterListItem.Site; @@ -126,7 +129,6 @@ import org.wordpress.android.util.DisplayUtils; import org.wordpress.android.util.DisplayUtilsWrapper; import org.wordpress.android.util.JetpackBrandingUtils; -import org.wordpress.android.models.JetpackPoweredScreen; import org.wordpress.android.util.NetworkUtils; import org.wordpress.android.util.QuickStartUtilsWrapper; import org.wordpress.android.util.SnackbarItem; @@ -144,8 +146,10 @@ import org.wordpress.android.widgets.RecyclerItemDecoration; import org.wordpress.android.widgets.WPSnackbar; +import java.io.Serializable; import java.util.ArrayList; import java.util.EnumSet; +import java.util.HashSet; import java.util.List; import java.util.Stack; @@ -165,34 +169,39 @@ public class ReaderPostListFragment extends ViewPagerFragment private static final int TAB_POSTS = 0; private static final int TAB_SITES = 1; private static final int NO_POSITION = -1; - + private static final String SUBFILTER_BOTTOM_SHEET_TAG = "SUBFILTER_BOTTOM_SHEET_TAG"; + private static boolean mHasPurgedReaderDb; + private final HistoryStack mTagPreviewHistory = new HistoryStack("tag_preview_history"); + @Inject ViewModelProvider.Factory mViewModelFactory; + @Inject AccountStore mAccountStore; + @Inject ReaderStore mReaderStore; + @Inject Dispatcher mDispatcher; + @Inject ImageManager mImageManager; + @Inject UiHelpers mUiHelpers; + @Inject TagUpdateClientUtilsProvider mTagUpdateClientUtilsProvider; + @Inject QuickStartUtilsWrapper mQuickStartUtilsWrapper; + @Inject SeenUnseenWithCounterFeatureConfig mSeenUnseenWithCounterFeatureConfig; + @Inject JetpackBrandingUtils mJetpackBrandingUtils; + @Inject QuickStartRepository mQuickStartRepository; + @Inject ReaderTracker mReaderTracker; + @Inject SnackbarSequencer mSnackbarSequencer; + @Inject DisplayUtilsWrapper mDisplayUtilsWrapper; + @Inject ReaderImprovementsFeatureConfig mReaderImprovementsFeatureConfig; private ReaderPostAdapter mPostAdapter; private ReaderSiteSearchAdapter mSiteSearchAdapter; private ReaderSearchSuggestionAdapter mSearchSuggestionAdapter; private ReaderSearchSuggestionRecyclerAdapter mSearchSuggestionRecyclerAdapter; - private FilteredRecyclerView mRecyclerView; private boolean mFirstLoad = true; - private View mNewPostsBar; private ActionableEmptyView mActionableEmptyView; private ProgressBar mProgress; private TabLayout mSearchTabs; - private SearchView mSearchView; private MenuItem mSearchMenuItem; - - private View mSubFilterComponent; - private View mSubFiltersListButton; - private TextView mSubFilterTitle; - private View mRemoveFilterButton; private View mJetpackBanner; - private boolean mIsTopLevel = false; - private static final String SUBFILTER_BOTTOM_SHEET_TAG = "SUBFILTER_BOTTOM_SHEET_TAG"; - private BottomNavController mBottomNavController; - private ReaderTag mCurrentTag; private ReaderTag mTagFragmentStartedWith = null; private long mCurrentBlogId; @@ -200,88 +209,86 @@ public class ReaderPostListFragment extends ViewPagerFragment private String mCurrentSearchQuery; private ReaderPostListType mPostListType; private ReaderSiteModel mLastTappedSiteSearchResult; - private int mRestorePosition; private int mSiteSearchRestorePosition; private int mPostSearchAdapterPos; private int mSiteSearchAdapterPos; private int mSearchTabsPos = NO_POSITION; + private boolean mIsFilterableScreen; + private boolean mIsFiltered = false; + private ActivityResultLauncher mReaderSubsActivityResultLauncher; + @NonNull private HashSet mCurrentUpdateActions = new HashSet<>(); + /* + * called by post adapter to load older posts when user scrolls to the last post + */ + private final ReaderActions.DataRequestedListener mDataRequestedListener = + new ReaderActions.DataRequestedListener() { + @Override + public void onRequestData() { + // skip if update is already in progress + if (isUpdating()) { + return; + } - private boolean mIsUpdating; + // request older posts unless we already have the max # to show + switch (getPostListType()) { + case TAG_FOLLOWED: + // fall through to TAG_PREVIEW + case TAG_PREVIEW: + if (ReaderPostTable.getNumPostsWithTag(mCurrentTag) + < ReaderConstants.READER_MAX_POSTS_TO_DISPLAY) { + // request older posts + updatePostsWithTag(getCurrentTag(), UpdateAction.REQUEST_OLDER); + mReaderTracker.track(AnalyticsTracker.Stat.READER_INFINITE_SCROLL); + } + break; + + case BLOG_PREVIEW: + int numPosts; + if (mCurrentFeedId != 0) { + numPosts = ReaderPostTable.getNumPostsInFeed(mCurrentFeedId); + } else { + numPosts = ReaderPostTable.getNumPostsInBlog(mCurrentBlogId); + } + if (numPosts < ReaderConstants.READER_MAX_POSTS_TO_DISPLAY) { + updatePostsInCurrentBlogOrFeed(UpdateAction.REQUEST_OLDER); + mReaderTracker.track(AnalyticsTracker.Stat.READER_INFINITE_SCROLL); + } + break; + + case SEARCH_RESULTS: + ReaderTag searchTag = ReaderUtils.getTagForSearchQuery(mCurrentSearchQuery); + int offset = ReaderPostTable.getNumPostsWithTag(searchTag); + if (offset < ReaderConstants.READER_MAX_POSTS_TO_DISPLAY) { + updatePostsInCurrentSearch(offset); + mReaderTracker.track(AnalyticsTracker.Stat.READER_INFINITE_SCROLL); + } + break; + } + } + }; private boolean mWasPaused; private boolean mHasRequestedPosts; private boolean mHasUpdatedPosts; private boolean mIsAnimatingOutNewPostsBar; - - private static boolean mHasPurgedReaderDb; - - private final HistoryStack mTagPreviewHistory = new HistoryStack("tag_preview_history"); - private AlertDialog mBookmarksSavedLocallyDialog; - private ReaderPostListViewModel mViewModel; // This VM is initialized only on the Following tab private SubFilterViewModel mSubFilterViewModel; private ReaderViewModel mReaderViewModel = null; - @Inject ViewModelProvider.Factory mViewModelFactory; - @Inject AccountStore mAccountStore; - @Inject ReaderStore mReaderStore; - @Inject Dispatcher mDispatcher; - @Inject ImageManager mImageManager; - @Inject UiHelpers mUiHelpers; - @Inject TagUpdateClientUtilsProvider mTagUpdateClientUtilsProvider; - @Inject QuickStartUtilsWrapper mQuickStartUtilsWrapper; - @Inject SeenUnseenWithCounterFeatureConfig mSeenUnseenWithCounterFeatureConfig; - @Inject JetpackBrandingUtils mJetpackBrandingUtils; - @Inject QuickStartRepository mQuickStartRepository; - @Inject ReaderTracker mReaderTracker; - @Inject SnackbarSequencer mSnackbarSequencer; - @Inject DisplayUtilsWrapper mDisplayUtilsWrapper; - @Inject ReaderImprovementsFeatureConfig mReaderImprovementsFeatureConfig; - - private enum ActionableEmptyViewButtonType { - DISCOVER, - FOLLOWED - } - - private static class HistoryStack extends Stack { - private final String mKeyName; - - HistoryStack(@SuppressWarnings("SameParameterValue") String keyName) { - mKeyName = keyName; - } - - void restoreInstance(Bundle bundle) { - clear(); - if (bundle.containsKey(mKeyName)) { - ArrayList history = bundle.getStringArrayList(mKeyName); - if (history != null) { - this.addAll(history); - } - } - } - - void saveInstance(Bundle bundle) { - if (!isEmpty()) { - ArrayList history = new ArrayList<>(); - history.addAll(this); - bundle.putStringArrayList(mKeyName, history); - } - } - } - /* * show posts with a specific tag (either TAG_FOLLOWED or TAG_PREVIEW) */ static ReaderPostListFragment newInstanceForTag(@NonNull ReaderTag tag, ReaderPostListType listType) { - return newInstanceForTag(tag, listType, false); + return newInstanceForTag(tag, listType, false, false); } static ReaderPostListFragment newInstanceForTag( @NonNull ReaderTag tag, ReaderPostListType listType, - boolean isTopLevel + boolean isTopLevel, + boolean isFilterable ) { AppLog.d(T.READER, "reader post list > newInstance (tag)"); @@ -292,6 +299,7 @@ static ReaderPostListFragment newInstanceForTag( args.putSerializable(ReaderConstants.ARG_TAG, tag); args.putSerializable(ReaderConstants.ARG_POST_LIST_TYPE, listType); args.putBoolean(ReaderConstants.ARG_IS_TOP_LEVEL, isTopLevel); + args.putBoolean(ReaderConstants.ARG_IS_FILTERABLE, isFilterable); ReaderPostListFragment fragment = new ReaderPostListFragment(); fragment.setArguments(args); @@ -369,6 +377,9 @@ public void setArguments(Bundle args) { if (args.containsKey(ReaderConstants.ARG_IS_TOP_LEVEL)) { mIsTopLevel = args.getBoolean(ReaderConstants.ARG_IS_TOP_LEVEL); } + if (args.containsKey(ReaderConstants.ARG_IS_FILTERABLE)) { + mIsFilterableScreen = args.getBoolean(ReaderConstants.ARG_IS_FILTERABLE); + } mCurrentBlogId = args.getLong(ReaderConstants.ARG_BLOG_ID); mCurrentFeedId = args.getLong(ReaderConstants.ARG_FEED_ID); @@ -409,6 +420,9 @@ public void onCreate(@Nullable Bundle savedInstanceState) { if (savedInstanceState.containsKey(ReaderConstants.ARG_IS_TOP_LEVEL)) { mIsTopLevel = savedInstanceState.getBoolean(ReaderConstants.ARG_IS_TOP_LEVEL); } + if (savedInstanceState.containsKey(ReaderConstants.ARG_IS_FILTERABLE)) { + mIsFilterableScreen = savedInstanceState.getBoolean(ReaderConstants.ARG_IS_FILTERABLE); + } if (savedInstanceState.containsKey(ReaderConstants.ARG_ORIGINAL_TAG)) { mTagFragmentStartedWith = @@ -436,7 +450,7 @@ public void onActivityCreated(@Nullable Bundle savedInstanceState) { .get(ReaderViewModel.class); } - if (isFilterableScreen()) { + if (mIsFilterableScreen) { initSubFilterViewModel(savedInstanceState); } @@ -590,39 +604,28 @@ private void addWebViewCachingFragment(Long blogId, Long postId) { private void initSubFilterViewModel(@Nullable Bundle savedInstanceState) { WPMainActivityViewModel wpMainActivityViewModel = new ViewModelProvider(requireActivity(), mViewModelFactory) .get(WPMainActivityViewModel.class); + mSubFilterViewModel = new ViewModelProvider(this, mViewModelFactory).get( - SubFilterViewModel.SUBFILTER_VM_BASE_KEY + mTagFragmentStartedWith.getKeyString(), + SubFilterViewModel.getViewModelKeyForTag(mTagFragmentStartedWith), SubFilterViewModel.class ); mSubFilterViewModel.getCurrentSubFilter().observe(getViewLifecycleOwner(), subfilterListItem -> { if (getPostListType() != ReaderPostListType.SEARCH_RESULTS) { mSubFilterViewModel.onSubfilterSelected(subfilterListItem); + if (shouldShowEmptyViewForSelfHostedCta()) { setEmptyTitleDescriptionAndButton(false); showEmptyView(); } + + if (mReaderViewModel != null) mReaderViewModel.onSubFilterItemSelected(subfilterListItem); } }); mSubFilterViewModel.getReaderModeInfo().observe(getViewLifecycleOwner(), readerModeInfo -> { if (readerModeInfo != null) { changeReaderMode(readerModeInfo, true); - - if (readerModeInfo.getLabel() != null) { - mSubFilterTitle.setText( - mUiHelpers.getTextOfUiString( - requireActivity(), - readerModeInfo.getLabel() - ) - ); - } - - if (readerModeInfo.isFiltered()) { - mRemoveFilterButton.setVisibility(View.VISIBLE); - } else { - mRemoveFilterButton.setVisibility(View.GONE); - } } }); @@ -636,11 +639,10 @@ private void initSubFilterViewModel(@Nullable Bundle savedInstanceState) { mSubFilterViewModel.loadSubFilters(); BottomSheetVisible visibleState = (BottomSheetVisible) uiState; bottomSheet = SubfilterBottomSheetFragment.newInstance( - SubFilterViewModel.SUBFILTER_VM_BASE_KEY + mTagFragmentStartedWith.getKeyString(), + SubFilterViewModel.getViewModelKeyForTag(mTagFragmentStartedWith), visibleState.getCategories(), mUiHelpers.getTextOfUiString(requireContext(), visibleState.getTitle()) ); - mReaderTracker.track(Stat.READER_FILTER_SHEET_DISPLAYED); bottomSheet.show(getChildFragmentManager(), SUBFILTER_BOTTOM_SHEET_TAG); } else if (!uiState.isVisible() && bottomSheet != null) { bottomSheet.dismiss(); @@ -650,17 +652,21 @@ private void initSubFilterViewModel(@Nullable Bundle savedInstanceState) { }); }); - mSubFilterViewModel.getBottomSheetEmptyViewAction().observe(getViewLifecycleOwner(), event -> { + mSubFilterViewModel.getBottomSheetAction().observe(getViewLifecycleOwner(), event -> { event.applyIfNotHandled(action -> { if (action instanceof OpenSubsAtPage) { - ReaderActivityLauncher.showReaderSubs( - requireActivity(), - ((OpenSubsAtPage) action).getTabIndex() - ); - } else { - wpMainActivityViewModel.onOpenLoginPage( - WPMainNavigationView.Companion.getPosition(PageType.MY_SITE) + mReaderSubsActivityResultLauncher.launch( + ReaderActivityLauncher.createIntentShowReaderSubs( + requireActivity(), + ((OpenSubsAtPage) action).getTabIndex() + ) ); + } else if (action instanceof OpenLoginPage) { + wpMainActivityViewModel.onOpenLoginPage(); + } else if (action instanceof OpenSearchPage) { + ReaderActivityLauncher.showReaderSearch(requireActivity()); + } else if (action instanceof OpenSuggestedTagsPage) { + ReaderActivityLauncher.showReaderInterests(requireActivity()); } return null; @@ -676,31 +682,16 @@ private void initSubFilterViewModel(@Nullable Bundle savedInstanceState) { }); }); - mSubFilterViewModel.start(mTagFragmentStartedWith, mCurrentTag, savedInstanceState); - } - - private void initSubFilterViews(ViewGroup rootView, LayoutInflater inflater) { - mSubFilterComponent = inflater.inflate(R.layout.subfilter_component, rootView, false); - ((ViewGroup) rootView.findViewById(R.id.sub_filter_component_container)).addView(mSubFilterComponent); - - mSubFiltersListButton = mSubFilterComponent.findViewById(R.id.filter_selection); - mSubFiltersListButton.setOnClickListener(v -> { - mSubFilterViewModel.onSubFiltersListButtonClicked(); - }); - - mSubFilterTitle = mSubFilterComponent.findViewById(R.id.selected_filter_name); - - mRemoveFilterButton = mSubFilterComponent.findViewById(R.id.remove_filter_button); - mRemoveFilterButton.setOnClickListener(v -> { - mReaderTracker.track(Stat.READER_FILTER_SHEET_CLEARED); - mSubFilterViewModel.setDefaultSubfilter(); - }); - mSubFilterComponent.setVisibility(isFilterableScreen() ? View.VISIBLE : View.GONE); + if (mIsFilterableScreen) { + mSubFilterViewModel.getSubFilters().observe(getViewLifecycleOwner(), subFilters -> { + mReaderViewModel.showTopBarFilterGroup(mTagFragmentStartedWith, subFilters); + }); + mSubFilterViewModel.updateTagsAndSites(); + } else { + mReaderViewModel.hideTopBarFilterGroup(mTagFragmentStartedWith); + } - ElevationOverlayProvider elevationOverlayProvider = new ElevationOverlayProvider(mRecyclerView.getContext()); - float cardElevation = getResources().getDimension(R.dimen.card_elevation); - int elevatedCardColor = elevationOverlayProvider.compositeOverlayWithThemeSurfaceColorIfNeeded(cardElevation); - mSubFilterComponent.setBackgroundColor(elevatedCardColor); + mSubFilterViewModel.start(mTagFragmentStartedWith, mCurrentTag, savedInstanceState); } private void changeReaderMode(ReaderModeInfo readerModeInfo, boolean onlyOnChanges) { @@ -729,6 +720,7 @@ private void changeReaderMode(ReaderModeInfo readerModeInfo, boolean onlyOnChang mPostListType = readerModeInfo.getListType(); mCurrentBlogId = readerModeInfo.getBlogId(); mCurrentFeedId = readerModeInfo.getFeedId(); + mIsFiltered = readerModeInfo.isFiltered(); resetPostAdapter(mPostListType); if (readerModeInfo.getRequestNewerPosts()) { @@ -745,7 +737,7 @@ public void onPause() { } mWasPaused = true; - mViewModel.onFragmentPause(mIsTopLevel, isSearching(), isFilterableScreen()); + mViewModel.onFragmentPause(mIsTopLevel, isSearching(), mIsFilterableScreen); } @Override @@ -792,8 +784,8 @@ public void onResume() { showEmptyView(); } - mViewModel.onFragmentResume(mIsTopLevel, isSearching(), isFilterableScreen(), - isFilterableScreen() ? mSubFilterViewModel.getCurrentSubfilterValue() : null); + mViewModel.onFragmentResume(mIsTopLevel, isSearching(), mIsFilterableScreen, + mIsFilterableScreen ? mSubFilterViewModel.getCurrentSubfilterValue() : null); } /* @@ -808,7 +800,7 @@ private void resumeFollowedTag() { mSubFilterViewModel.setSubfilterFromTag(newTag); } else if (isFollowingScreen() && !ReaderTagTable.tagExists(getCurrentTag())) { // user just removed a tag which was selected in the subfilter - mSubFilterViewModel.setDefaultSubfilter(); + mSubFilterViewModel.setDefaultSubfilter(false); } else { // otherwise, refresh posts to make sure any changes are reflected and auto-update // posts in the current tag if it's time @@ -821,7 +813,7 @@ private void resumeFollowedTag() { private Site getSiteIfBlogPreview() { Site currentSite = null; - if (isFilterableScreen() && getPostListType() == ReaderPostListType.BLOG_PREVIEW) { + if (mIsFilterableScreen && getPostListType() == ReaderPostListType.BLOG_PREVIEW) { currentSite = mSubFilterViewModel.getCurrentSubfilterValue() instanceof Site ? (Site) (mSubFilterViewModel .getCurrentSubfilterValue()) : null; } @@ -843,8 +835,8 @@ private void resumeFollowedSite(Site currentSite) { if (isSiteStillAvailable) { refreshPosts(); } else { - if (isFilterableScreen()) { - mSubFilterViewModel.setDefaultSubfilter(); + if (mIsFilterableScreen) { + mSubFilterViewModel.setDefaultSubfilter(false); } } } @@ -859,6 +851,25 @@ public void onAttach(@NonNull Context context) { if (context instanceof BottomNavController) { mBottomNavController = (BottomNavController) context; } + + initReaderSubsActivityResultLauncher(); + } + + private void initReaderSubsActivityResultLauncher() { + mReaderSubsActivityResultLauncher = registerForActivityResult( + new ActivityResultContracts.StartActivityForResult(), + result -> { + if (result.getResultCode() == Activity.RESULT_OK) { + final Intent data = result.getData(); + if (data != null) { + final boolean shouldRefreshSubscriptions = + data.getBooleanExtra(ReaderSubsActivity.RESULT_SHOULD_REFRESH_SUBSCRIPTIONS, false); + if (shouldRefreshSubscriptions) { + mSubFilterViewModel.loadSubFilters(); + } + } + } + }); } @Override @@ -972,12 +983,13 @@ public void onSaveInstanceState(@NonNull Bundle outState) { outState.putBoolean(ReaderConstants.KEY_ALREADY_REQUESTED, mHasRequestedPosts); outState.putBoolean(ReaderConstants.KEY_ALREADY_UPDATED, mHasUpdatedPosts); outState.putBoolean(ReaderConstants.KEY_FIRST_LOAD, mFirstLoad); + outState.putSerializable(ReaderConstants.KEY_CURRENT_UPDATE_ACTIONS, mCurrentUpdateActions); if (mRecyclerView != null) { - outState.putBoolean(ReaderConstants.KEY_IS_REFRESHING, mRecyclerView.isRefreshing()); outState.putInt(ReaderConstants.KEY_RESTORE_POSITION, getCurrentPosition()); } outState.putSerializable(ReaderConstants.ARG_POST_LIST_TYPE, getPostListType()); outState.putBoolean(ReaderConstants.ARG_IS_TOP_LEVEL, mIsTopLevel); + outState.putBoolean(ReaderConstants.ARG_IS_FILTERABLE, mIsFilterableScreen); if (isSearchTabsShowing()) { int tabPosition = getSearchTabsPosition(); @@ -986,7 +998,7 @@ public void onSaveInstanceState(@NonNull Bundle outState) { outState.putInt(ReaderConstants.KEY_SITE_SEARCH_RESTORE_POSITION, siteSearchPosition); } - if (isFilterableTag(mTagFragmentStartedWith) && mSubFilterViewModel != null) { + if (mIsFilterableScreen && mSubFilterViewModel != null) { mSubFilterViewModel.onSaveInstanceState(outState); } @@ -1014,7 +1026,7 @@ private void updatePosts(boolean forced) { if (forced) { // Update the tags on post refresh since following some sites (like P2) will change followed tags and blogs ReaderUpdateServiceStarter.startService(getContext(), - EnumSet.of(UpdateTask.TAGS)); + EnumSet.of(UpdateTask.TAGS, UpdateTask.FOLLOWED_BLOGS)); } if (mFirstLoad) { @@ -1194,14 +1206,12 @@ public void onShowCustomEmptyView(EmptyViewMessageType emptyViewMsgType) { } } - if (savedInstanceState != null && savedInstanceState.getBoolean(ReaderConstants.KEY_IS_REFRESHING)) { - mIsUpdating = true; - mRecyclerView.setRefreshing(true); - } - - - if (isFilterableScreen()) { - initSubFilterViews(rootView, inflater); + if (savedInstanceState != null) { + Serializable actions = savedInstanceState.getSerializable(ReaderConstants.KEY_CURRENT_UPDATE_ACTIONS); + if (actions instanceof HashSet) { + mCurrentUpdateActions = (HashSet) actions; + updateProgressIndicators(); + } } return rootView; @@ -1582,7 +1592,6 @@ private void populateSearchSuggestionAdapter(String query) { mSearchSuggestionAdapter.setFilter(query); } - private void createSearchSuggestionRecyclerAdapter() { mSearchSuggestionRecyclerAdapter = new ReaderSearchSuggestionRecyclerAdapter(); mRecyclerView.setSearchSuggestionAdapter(mSearchSuggestionRecyclerAdapter); @@ -1704,13 +1713,16 @@ private void setEmptyTitleDescriptionAndButton(boolean requestFailed) { int heightTabs = getActivity().getResources().getDimensionPixelSize(R.dimen.tab_height); mActionableEmptyView.updateLayoutForSearch(false, 0); mActionableEmptyView.subtitle.setContentDescription(null); - boolean isSearching = false; + boolean isImageHidden = false; String title; String description = null; ActionableEmptyViewButtonType button = null; // Ensure the default image is reset for empty views before applying logic - mActionableEmptyView.image.setImageResource(R.drawable.img_illustration_empty_results_216dp); + mActionableEmptyView.image.setImageResource(R.drawable.illustration_reader_empty); + + // TODO thomashortadev + // try to quickly hack some way of making the button black if (shouldShowEmptyViewForSelfHostedCta()) { setEmptyTitleAndDescriptionForSelfHostedCta(); @@ -1719,7 +1731,7 @@ private void setEmptyTitleDescriptionAndButton(boolean requestFailed) { setEmptyTitleAndDescriptionForBookmarksList(); return; } else if (!NetworkUtils.isNetworkAvailable(getActivity())) { - mIsUpdating = false; + clearCurrentUpdateActions(); title = getString(R.string.reader_empty_posts_no_connection); } else if (requestFailed) { if (isSearching()) { @@ -1733,31 +1745,33 @@ private void setEmptyTitleDescriptionAndButton(boolean requestFailed) { switch (getPostListType()) { case TAG_FOLLOWED: if (getCurrentTag().isFollowedSites() || getCurrentTag().isDefaultInMemoryTag()) { + isImageHidden = true; + if (ReaderBlogTable.hasFollowedBlogs()) { title = getString(R.string.reader_empty_followed_blogs_no_recent_posts_title); - description = getString(R.string.reader_empty_followed_blogs_no_recent_posts_description); + description = getString( + R.string.reader_empty_followed_blogs_subscribed_no_recent_posts_description); } else { - title = getString(R.string.reader_empty_followed_blogs_title); - description = getString(R.string.reader_empty_followed_blogs_description); + title = getString(R.string.reader_no_followed_blogs_title); + description = getString(R.string.reader_no_followed_blogs_description); } - mActionableEmptyView.image.setImageResource( - R.drawable.img_illustration_following_empty_results_196dp); + button = ActionableEmptyViewButtonType.DISCOVER; } else if (getCurrentTag().isPostsILike()) { title = getString(R.string.reader_empty_posts_liked_title); description = getString(R.string.reader_empty_posts_liked_description); button = ActionableEmptyViewButtonType.FOLLOWED; } else if (getCurrentTag().isListTopic()) { - title = getString(R.string.reader_empty_posts_in_custom_list); + title = getString(R.string.reader_empty_blogs_posts_in_custom_list); } else { - title = getString(R.string.reader_empty_posts_in_tag); + title = getString(R.string.reader_no_posts_with_this_tag); } break; case BLOG_PREVIEW: - title = getString(R.string.reader_empty_posts_in_blog); + title = getString(R.string.reader_no_posts_in_blog); break; case SEARCH_RESULTS: - isSearching = true; + isImageHidden = true; if (isSearchViewEmpty() || TextUtils.isEmpty(mCurrentSearchQuery)) { title = getString(R.string.reader_label_post_search_explainer); @@ -1776,12 +1790,12 @@ private void setEmptyTitleDescriptionAndButton(boolean requestFailed) { case TAG_PREVIEW: // fall through to the default case default: - title = getString(R.string.reader_empty_posts_in_tag); + title = getString(R.string.reader_no_posts_with_this_tag); break; } } - setEmptyTitleDescriptionAndButton(title, description, button, isSearching); + setEmptyTitleDescriptionAndButton(title, description, button, isImageHidden); } /* @@ -1799,7 +1813,7 @@ private void setEmptyTitleAndDescriptionForBookmarksList() { mActionableEmptyView.subtitle .setContentDescription(getString(R.string.reader_empty_saved_posts_content_description)); mActionableEmptyView.subtitle.setVisibility(View.VISIBLE); - mActionableEmptyView.button.setText(R.string.reader_empty_followed_blogs_button_followed); + mActionableEmptyView.button.setText(R.string.reader_empty_followed_blogs_button_subscriptions); mActionableEmptyView.button.setVisibility(View.VISIBLE); mActionableEmptyView.button.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View view) { @@ -1809,7 +1823,7 @@ private void setEmptyTitleAndDescriptionForBookmarksList() { } private boolean shouldShowEmptyViewForSelfHostedCta() { - return isFilterableScreen() && !mAccountStore.hasAccessToken() && mSubFilterViewModel + return mIsFilterableScreen && !mAccountStore.hasAccessToken() && mSubFilterViewModel .getCurrentSubfilterValue() instanceof SiteAll; } @@ -1832,12 +1846,13 @@ private void addBookmarkImageSpan(SpannableStringBuilder ssb, int imagePlacehold } private void setEmptyTitleDescriptionAndButton(@NonNull String title, String description, - final ActionableEmptyViewButtonType button, boolean isSearching) { + final ActionableEmptyViewButtonType button, + boolean isImageHidden) { if (!isAdded()) { return; } - mActionableEmptyView.image.setVisibility(!isUpdating() && !isSearching ? View.VISIBLE : View.GONE); + mActionableEmptyView.image.setVisibility(!isUpdating() && !isImageHidden ? View.VISIBLE : View.GONE); mActionableEmptyView.title.setText(title); if (description == null) { @@ -1861,10 +1876,10 @@ private void setEmptyTitleDescriptionAndButton(@NonNull String title, String des switch (button) { case DISCOVER: - mActionableEmptyView.button.setText(R.string.reader_empty_followed_blogs_button_discover); + mActionableEmptyView.button.setText(R.string.reader_no_followed_blogs_button_discover); break; case FOLLOWED: - mActionableEmptyView.button.setText(R.string.reader_empty_followed_blogs_button_followed); + mActionableEmptyView.button.setText(R.string.reader_empty_followed_blogs_button_subscriptions); break; } @@ -1907,7 +1922,22 @@ private void setCurrentTagFromEmptyViewButton(ActionableEmptyViewButtonType butt mViewModel.onEmptyStateButtonTapped(tag); } - /* + private void announceListStateForAccessibility() { + if (getView() != null) { + getView().announceForAccessibility(getString(R.string.reader_acessibility_list_loaded, + getPostAdapter().getItemCount())); + } + } + + private void showBookmarksSavedLocallyDialog(ShowBookmarkedSavedOnlyLocallyDialog holder) { + mBookmarksSavedLocallyDialog = new MaterialAlertDialogBuilder(requireActivity()) + .setTitle(getString(holder.getTitle())) + .setMessage(getString(holder.getMessage())) + .setPositiveButton(holder.getButtonLabel(), (dialog, which) -> holder.getOkButtonAction().invoke()) + .setCancelable(false) + .create(); + mBookmarksSavedLocallyDialog.show(); + } /* * called by post adapter when data has been loaded */ private final ReaderInterfaces.DataLoadedListener mDataLoadedListener = new ReaderInterfaces.DataLoadedListener() { @@ -1940,78 +1970,11 @@ public void onDataLoaded(boolean isEmpty) { } }; - private void announceListStateForAccessibility() { - if (getView() != null) { - getView().announceForAccessibility(getString(R.string.reader_acessibility_list_loaded, - getPostAdapter().getItemCount())); - } - } - - private void showBookmarksSavedLocallyDialog(ShowBookmarkedSavedOnlyLocallyDialog holder) { - mBookmarksSavedLocallyDialog = new MaterialAlertDialogBuilder(requireActivity()) - .setTitle(getString(holder.getTitle())) - .setMessage(getString(holder.getMessage())) - .setPositiveButton(holder.getButtonLabel(), (dialog, which) -> holder.getOkButtonAction().invoke()) - .setCancelable(false) - .create(); - mBookmarksSavedLocallyDialog.show(); - } - private boolean isBookmarksList() { return getPostListType() == ReaderPostListType.TAG_FOLLOWED && (mCurrentTag != null && mCurrentTag.isBookmarked()); } - /* - * called by post adapter to load older posts when user scrolls to the last post - */ - private final ReaderActions.DataRequestedListener mDataRequestedListener = - new ReaderActions.DataRequestedListener() { - @Override - public void onRequestData() { - // skip if update is already in progress - if (isUpdating()) { - return; - } - - // request older posts unless we already have the max # to show - switch (getPostListType()) { - case TAG_FOLLOWED: - // fall through to TAG_PREVIEW - case TAG_PREVIEW: - if (ReaderPostTable.getNumPostsWithTag(mCurrentTag) - < ReaderConstants.READER_MAX_POSTS_TO_DISPLAY) { - // request older posts - updatePostsWithTag(getCurrentTag(), UpdateAction.REQUEST_OLDER); - mReaderTracker.track(AnalyticsTracker.Stat.READER_INFINITE_SCROLL); - } - break; - - case BLOG_PREVIEW: - int numPosts; - if (mCurrentFeedId != 0) { - numPosts = ReaderPostTable.getNumPostsInFeed(mCurrentFeedId); - } else { - numPosts = ReaderPostTable.getNumPostsInBlog(mCurrentBlogId); - } - if (numPosts < ReaderConstants.READER_MAX_POSTS_TO_DISPLAY) { - updatePostsInCurrentBlogOrFeed(UpdateAction.REQUEST_OLDER); - mReaderTracker.track(AnalyticsTracker.Stat.READER_INFINITE_SCROLL); - } - break; - - case SEARCH_RESULTS: - ReaderTag searchTag = ReaderUtils.getTagForSearchQuery(mCurrentSearchQuery); - int offset = ReaderPostTable.getNumPostsWithTag(searchTag); - if (offset < ReaderConstants.READER_MAX_POSTS_TO_DISPLAY) { - updatePostsInCurrentSearch(offset); - mReaderTracker.track(AnalyticsTracker.Stat.READER_INFINITE_SCROLL); - } - break; - } - } - }; - private ReaderPostAdapter getPostAdapter() { if (mPostAdapter == null) { AppLog.d(T.READER, "reader post list > creating post adapter"); @@ -2089,14 +2052,6 @@ private ReaderTag getCurrentTag() { return mCurrentTag; } - private String getCurrentTagName() { - return (mCurrentTag != null ? mCurrentTag.getTagSlug() : ""); - } - - private boolean hasCurrentTag() { - return mCurrentTag != null; - } - private void setCurrentTag(final ReaderTag tag) { if (tag == null) { return; @@ -2111,7 +2066,7 @@ && getPostAdapter().isCurrentTag(tag)) { mCurrentTag = tag; - if (isFilterableScreen()) { + if (mIsFilterableScreen) { if (isFilterableTag(mCurrentTag) || mCurrentTag.isDefaultInMemoryTag()) { mSubFilterViewModel.onSubfilterReselected(); } else { @@ -2123,7 +2078,7 @@ && getPostAdapter().isCurrentTag(tag)) { false, null, false, - mRemoveFilterButton.getVisibility() == View.VISIBLE), + mIsFiltered), false ); } @@ -2167,6 +2122,14 @@ && getPostAdapter().isCurrentTag(tag)) { updateCurrentTagIfTime(); } + private String getCurrentTagName() { + return (mCurrentTag != null ? mCurrentTag.getTagSlug() : ""); + } + + private boolean hasCurrentTag() { + return mCurrentTag != null; + } + @Override public View getScrollableViewForUniqueIdProvision() { return mRecyclerView.getInternalRecyclerView(); @@ -2351,10 +2314,24 @@ private void updateCurrentTagIfTime() { @Override public void run() { if (ReaderTagTable.shouldAutoUpdateTag(getCurrentTag()) && isAdded()) { - getActivity().runOnUiThread(new Runnable() { - @Override - public void run() { - updateCurrentTag(); + requireActivity().runOnUiThread(() -> updateCurrentTag()); + } else { + requireActivity().runOnUiThread(() -> { + if ((isBookmarksList()) && isPostAdapterEmpty() && isAdded()) { + setEmptyTitleAndDescriptionForBookmarksList(); + mActionableEmptyView.image.setImageResource( + R.drawable.illustration_reader_empty); + showEmptyView(); + } else if (getCurrentTag().isListTopic() && isPostAdapterEmpty() && isAdded()) { + mActionableEmptyView.image.setImageResource( + R.drawable.illustration_reader_empty); + mActionableEmptyView.title.setText( + getString(R.string.reader_empty_blogs_posts_in_custom_list)); + mActionableEmptyView.button.setVisibility(View.GONE); + mActionableEmptyView.subtitle.setVisibility(View.GONE); + showEmptyView(); + } else { + hideEmptyView(); } }); } @@ -2363,7 +2340,7 @@ public void run() { } private boolean isUpdating() { - return mIsUpdating; + return mCurrentUpdateActions.size() > 0; } /* @@ -2380,27 +2357,45 @@ private void showLoadingProgress(boolean showProgress) { } } - private void setIsUpdating(boolean isUpdating, UpdateAction updateAction) { - if (!isAdded() || mIsUpdating == isUpdating) { - return; - } + private void clearCurrentUpdateActions() { + if (!isAdded() || !isUpdating()) return; - if (updateAction == UpdateAction.REQUEST_OLDER) { - // show/hide progress bar at bottom if these are older posts - showLoadingProgress(isUpdating); - } else if (isUpdating) { - // show swipe-to-refresh if update started - mRecyclerView.setRefreshing(true); + mCurrentUpdateActions.clear(); + updateProgressIndicators(); + } + + private void setIsUpdating(boolean isUpdating, @NonNull UpdateAction updateAction) { + if (!isAdded()) return; + + boolean isUiUpdateNeeded; + if (isUpdating) { + isUiUpdateNeeded = mCurrentUpdateActions.add(updateAction); } else { - // hide swipe-to-refresh progress if update is complete + isUiUpdateNeeded = mCurrentUpdateActions.remove(updateAction); + } + + if (isUiUpdateNeeded) updateProgressIndicators(); + } + + private void updateProgressIndicators() { + if (!isUpdating()) { + // when there's no update in progress, hide the bottom and swipe-to-refresh progress bars + showLoadingProgress(false); mRecyclerView.setRefreshing(false); + } else if (mCurrentUpdateActions.size() == 1 && mCurrentUpdateActions.contains(UpdateAction.REQUEST_OLDER)) { + // if only older posts are being updated, show only the bottom progress bar + showLoadingProgress(true); + mRecyclerView.setRefreshing(false); + } else { + // if anything else is being updated, show only the swipe-to-refresh progress bar + showLoadingProgress(false); + mRecyclerView.setRefreshing(true); } - mIsUpdating = isUpdating; // if swipe-to-refresh isn't active, keep it disabled during an update - this prevents // doing a refresh while another update is already in progress - if (mRecyclerView != null && !mRecyclerView.isRefreshing()) { - mRecyclerView.setSwipeToRefreshEnabled(!isUpdating && isSwipeToRefreshSupported()); + if (!mRecyclerView.isRefreshing()) { + mRecyclerView.setSwipeToRefreshEnabled(!isUpdating() && isSwipeToRefreshSupported()); } } @@ -2419,18 +2414,6 @@ private boolean isNewPostsBarShowing() { return (mNewPostsBar != null && mNewPostsBar.getVisibility() == View.VISIBLE); } - /* - * scroll listener assigned to the recycler when the "new posts" bar is shown to hide - * it upon scrolling - */ - private final RecyclerView.OnScrollListener mOnScrollListener = new RecyclerView.OnScrollListener() { - @Override - public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) { - super.onScrolled(recyclerView, dx, dy); - hideNewPostsBar(); - } - }; - private void showNewPostsBar() { if (!isAdded() || isNewPostsBarShowing()) { return; @@ -2579,7 +2562,17 @@ public void onPostSelected(ReaderPost post) { ReaderActivityLauncher.showReaderPostDetail(getActivity(), post.blogId, post.postId); break; } - } + } /* + * scroll listener assigned to the recycler when the "new posts" bar is shown to hide + * it upon scrolling + */ + private final RecyclerView.OnScrollListener mOnScrollListener = new RecyclerView.OnScrollListener() { + @Override + public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) { + super.onScrolled(recyclerView, dx, dy); + hideNewPostsBar(); + } + }; /* * called when user selects a tag from the tag toolbar @@ -2827,11 +2820,38 @@ private boolean isFollowingScreen() { return mTagFragmentStartedWith != null && mTagFragmentStartedWith.isFollowedSites(); } - private boolean isFilterableScreen() { - return isFilterableTag(mTagFragmentStartedWith); - } - private boolean isFilterableTag(ReaderTag tag) { return tag != null && tag.isFilterable(); } + + private enum ActionableEmptyViewButtonType { + DISCOVER, + FOLLOWED + } + + private static class HistoryStack extends Stack { + private final String mKeyName; + + HistoryStack(@SuppressWarnings("SameParameterValue") String keyName) { + mKeyName = keyName; + } + + void restoreInstance(Bundle bundle) { + clear(); + if (bundle.containsKey(mKeyName)) { + ArrayList history = bundle.getStringArrayList(mKeyName); + if (history != null) { + this.addAll(history); + } + } + } + + void saveInstance(Bundle bundle) { + if (!isEmpty()) { + ArrayList history = new ArrayList<>(); + history.addAll(this); + bundle.putStringArrayList(mKeyName, history); + } + } + } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderSubsActivity.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderSubsActivity.java index 202ce2102dca..9b0d02c08482 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderSubsActivity.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderSubsActivity.java @@ -87,6 +87,8 @@ public class ReaderSubsActivity extends LocaleAwareActivity public static final int TAB_IDX_FOLLOWED_TAGS = 0; public static final int TAB_IDX_FOLLOWED_BLOGS = 1; + public static final String RESULT_SHOULD_REFRESH_SUBSCRIPTIONS = "should_refresh_subscriptions"; + @Inject AccountStore mAccountStore; @Inject ReaderTracker mReaderTracker; @@ -98,10 +100,8 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { OnBackPressedCallback callback = new OnBackPressedCallback(true) { @Override public void handleOnBackPressed() { - if (!TextUtils.isEmpty(mLastAddedTagName)) { - EventBus.getDefault().postSticky(new ReaderEvents.TagAdded(mLastAddedTagName)); - } mReaderTracker.track(Stat.READER_MANAGE_VIEW_DISMISSED); + setResult(); CompatExtensionsKt.onBackPressedCompat(getOnBackPressedDispatcher(), this); } }; @@ -171,6 +171,20 @@ public void onPageSelected(int position) { mReaderTracker.track(Stat.READER_MANAGE_VIEW_DISPLAYED); } + private void setResult() { + final Intent data = new Intent(); + boolean shouldRefreshSubscriptions = false; + if (mPageAdapter != null) { + final ReaderTagFragment readerTagFragment = mPageAdapter.getReaderTagFragment(); + final ReaderBlogFragment readerBlogFragment = mPageAdapter.getReaderBlogFragment(); + if (readerTagFragment != null && readerBlogFragment != null) { + shouldRefreshSubscriptions = readerTagFragment.hasChangedSelectedTags(); + } + } + data.putExtra(RESULT_SHOULD_REFRESH_SUBSCRIPTIONS, shouldRefreshSubscriptions); + setResult(RESULT_OK, data); + } + @Override protected void onPause() { EventBus.getDefault().unregister(this); @@ -278,12 +292,12 @@ private void addAsTag(final String entry) { } if (!ReaderTag.isValidTagName(entry)) { - showInfoSnackbar(getString(R.string.reader_toast_err_tag_invalid)); + showInfoSnackbar(getString(R.string.reader_toast_err_tag_not_valid)); return; } if (ReaderTagTable.isFollowedTagName(entry)) { - showInfoSnackbar(getString(R.string.reader_toast_err_tag_exists)); + showInfoSnackbar(getString(R.string.reader_toast_err_tag_already_subscribed)); return; } @@ -317,7 +331,7 @@ private void addAsUrl(final String entry) { // make sure it isn't already followed if (ReaderBlogTable.isFollowedBlogUrl(normUrl) || ReaderBlogTable.isFollowedFeedUrl(normUrl)) { - showInfoSnackbar(getString(R.string.reader_toast_err_already_follow_blog)); + showInfoSnackbar(getString(R.string.reader_toast_err_already_follow_this_blog)); return; } @@ -353,7 +367,7 @@ private void performAddTag(final String tagName) { ReaderTracker.SOURCE_SETTINGS ); } else { - showInfoSnackbar(getString(R.string.reader_toast_err_add_tag)); + showInfoSnackbar(getString(R.string.reader_toast_err_adding_tag)); mLastAddedTagName = null; } }; @@ -389,14 +403,15 @@ public void onFailure(int statusCode) { String errMsg; switch (statusCode) { case 401: - errMsg = getString(R.string.reader_toast_err_follow_blog_not_authorized); + errMsg = getString(R.string.reader_toast_err_follow_not_authorized_to_access_blog); break; case 0: // can happen when host name not found case 404: - errMsg = getString(R.string.reader_toast_err_follow_blog_not_found); + errMsg = getString(R.string.reader_toast_err_follow_blog_could_not_be_found); break; default: - errMsg = getString(R.string.reader_toast_err_follow_blog) + " (" + statusCode + ")"; + errMsg = getString(R.string.reader_toast_err_unable_to_follow_blog) + " (" + statusCode + + ")"; break; } showInfoSnackbar(errMsg); @@ -416,14 +431,14 @@ private void followBlogUrl(String normUrl) { // clear the edit text and hide the soft keyboard mEditAdd.setText(null); EditTextUtils.hideSoftInput(mEditAdd); - showInfoSnackbar(getString(R.string.reader_label_followed_blog)); + showInfoSnackbar(getString(R.string.reader_label_blog_subscribed)); getPageAdapter().refreshBlogFragments(ReaderBlogType.FOLLOWED); // update tags if the site we added belongs to a tag we don't yet have // also update followed blogs so lists are ready in case we need to present them // in bottom sheet reader filtering performUpdate(EnumSet.of(UpdateTask.TAGS, UpdateTask.FOLLOWED_BLOGS)); } else { - showInfoSnackbar(getString(R.string.reader_toast_err_follow_blog)); + showInfoSnackbar(getString(R.string.reader_toast_err_unable_to_follow_blog)); } }; // note that this uses the endpoint to follow as a feed since typed URLs are more @@ -537,9 +552,9 @@ private class SubsPageAdapter extends FragmentPagerAdapter { public CharSequence getPageTitle(int position) { switch (position) { case TAB_IDX_FOLLOWED_TAGS: - return getString(R.string.reader_page_followed_tags); + return getString(R.string.reader_page_followed_tags_title); case TAB_IDX_FOLLOWED_BLOGS: - return getString(R.string.reader_page_followed_blogs); + return getString(R.string.reader_page_followed_blogs_title); default: return super.getPageTitle(position); } @@ -563,12 +578,30 @@ public Object instantiateItem(ViewGroup container, int position) { } private void refreshFollowedTagFragment() { - for (Fragment fragment : mFragments) { + final ReaderTagFragment fragment = getReaderTagFragment(); + if (fragment != null) { + fragment.refresh(); + } + } + + @Nullable + private ReaderTagFragment getReaderTagFragment() { + for (final Fragment fragment : mFragments) { if (fragment instanceof ReaderTagFragment) { - ReaderTagFragment tagFragment = (ReaderTagFragment) fragment; - tagFragment.refresh(); + return (ReaderTagFragment) fragment; + } + } + return null; + } + + @Nullable + private ReaderBlogFragment getReaderBlogFragment() { + for (final Fragment fragment : mFragments) { + if (fragment instanceof ReaderBlogFragment) { + return (ReaderBlogFragment) fragment; } } + return null; } private void refreshBlogFragments(ReaderBlogType blogType) { diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderTagFragment.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderTagFragment.java index ee2a37de089f..1638e7ae1eb0 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderTagFragment.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/ReaderTagFragment.java @@ -11,12 +11,17 @@ import org.wordpress.android.R; import org.wordpress.android.models.ReaderTag; +import org.wordpress.android.models.ReaderTagList; import org.wordpress.android.ui.ActionableEmptyView; import org.wordpress.android.ui.reader.adapters.ReaderTagAdapter; import org.wordpress.android.ui.reader.views.ReaderRecyclerView; import org.wordpress.android.util.AppLog; import org.wordpress.android.util.WPActivityUtils; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + /* * fragment hosted by ReaderSubsActivity which shows followed tags */ @@ -24,6 +29,9 @@ public class ReaderTagFragment extends Fragment implements ReaderTagAdapter.TagD private ReaderRecyclerView mRecyclerView; private ReaderTagAdapter mTagAdapter; + private boolean mIsFirstDataLoaded; + private final ReaderTagList mInitialReaderTagList = new ReaderTagList(); + static ReaderTagFragment newInstance() { AppLog.d(AppLog.T.READER, "reader tag list > newInstance"); return new ReaderTagFragment(); @@ -36,6 +44,21 @@ public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, return view; } + public boolean hasChangedSelectedTags() { + final Set initialTagsSlugs = new HashSet<>(); + for (final ReaderTag readerTag : mInitialReaderTagList) { + initialTagsSlugs.add(readerTag.getTagSlug()); + } + final List currentReaderTagList = getTagAdapter().getItems(); + final Set currentTagsSlugs = new HashSet<>(); + if (currentReaderTagList != null) { + for (final ReaderTag readerTag : currentReaderTagList) { + currentTagsSlugs.add(readerTag.getTagSlug()); + } + } + return !(initialTagsSlugs.equals(currentTagsSlugs)); + } + private void checkEmptyView() { if (!isAdded() || getView() == null) { return; @@ -47,10 +70,9 @@ private void checkEmptyView() { return; } - actionableEmptyView.image.setImageResource(R.drawable.img_illustration_empty_results_216dp); - actionableEmptyView.image.setVisibility(View.VISIBLE); - actionableEmptyView.title.setText(R.string.reader_empty_followed_tags_title); - actionableEmptyView.subtitle.setText(R.string.reader_empty_followed_tags_subtitle); + actionableEmptyView.image.setVisibility(View.GONE); + actionableEmptyView.title.setText(R.string.reader_no_followed_tags_title); + actionableEmptyView.subtitle.setText(R.string.reader_empty_subscribed_tags_subtitle); actionableEmptyView.subtitle.setVisibility(View.VISIBLE); actionableEmptyView.setVisibility(hasTagAdapter() && getTagAdapter().isEmpty() ? View.VISIBLE : View.GONE); } @@ -63,6 +85,12 @@ public void onActivityCreated(Bundle savedInstanceState) { refresh(); } + @Override + public void onAttach(Context context) { + super.onAttach(context); + mIsFirstDataLoaded = true; + } + void refresh() { if (hasTagAdapter()) { AppLog.d(AppLog.T.READER, "reader subs > refreshing tag fragment"); @@ -75,10 +103,14 @@ private ReaderTagAdapter getTagAdapter() { Context context = WPActivityUtils.getThemedContext(getActivity()); mTagAdapter = new ReaderTagAdapter(context); mTagAdapter.setTagDeletedListener(this); - mTagAdapter.setDataLoadedListener(new ReaderInterfaces.DataLoadedListener() { - @Override - public void onDataLoaded(boolean isEmpty) { - checkEmptyView(); + mTagAdapter.setDataLoadedListener(isEmpty -> { + checkEmptyView(); + if (mIsFirstDataLoaded) { + mIsFirstDataLoaded = false; + mInitialReaderTagList.clear(); + if (mTagAdapter != null && mTagAdapter.getItems() != null) { + mInitialReaderTagList.addAll(mTagAdapter.getItems()); + } } }); } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/SubfilterBottomSheetFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/SubfilterBottomSheetFragment.kt index 7f4ae2e3cdc8..01de1e11aa52 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/SubfilterBottomSheetFragment.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/SubfilterBottomSheetFragment.kt @@ -8,16 +8,16 @@ import android.view.View import android.view.ViewGroup import android.widget.FrameLayout import android.widget.TextView -import androidx.lifecycle.Observer +import androidx.core.view.isVisible import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelStoreOwner import androidx.viewpager.widget.ViewPager import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import com.google.android.material.tabs.TabLayout import org.wordpress.android.R import org.wordpress.android.WordPress +import org.wordpress.android.ui.reader.subfilter.ActionType import org.wordpress.android.ui.reader.subfilter.SubFilterViewModel import org.wordpress.android.ui.reader.subfilter.SubfilterCategory import org.wordpress.android.ui.reader.subfilter.SubfilterCategory.SITES @@ -77,9 +77,11 @@ class SubfilterBottomSheetFragment : BottomSheetDialogFragment() { viewModelFactory )[subfilterVmKey, SubFilterViewModel::class.java] + // TODO remove the pager and support only one category val pager = view.findViewById(R.id.view_pager) - val tabLayout = view.findViewById(R.id.tab_layout) + val titleContainer = view.findViewById(R.id.title_container) val title = view.findViewById(R.id.title) + val editSubscriptions = view.findViewById(R.id.edit_subscriptions) title.text = bottomSheetTitle pager.adapter = SubfilterPagerAdapter( requireActivity(), @@ -87,7 +89,6 @@ class SubfilterBottomSheetFragment : BottomSheetDialogFragment() { subfilterVmKey, categories.toList() ) - tabLayout.setupWithViewPager(pager) pager.addOnPageChangeListener(object : ViewPager.OnPageChangeListener { override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) { // NO OP @@ -108,14 +109,14 @@ class SubfilterBottomSheetFragment : BottomSheetDialogFragment() { else -> SITES.ordinal } - viewModel.filtersMatchCount.observe(this, Observer { - for (category in it.keys) { - val tab = tabLayout.getTabAt(category.ordinal) - tab?.let { sectionTab -> - sectionTab.text = "${view.context.getString(category.titleRes)} (${it[category]})" - } + editSubscriptions.setOnClickListener { + val category = categories.firstOrNull() ?: return@setOnClickListener + val subsPageIndex = when (category) { + SITES -> ReaderSubsActivity.TAB_IDX_FOLLOWED_BLOGS + TAGS -> ReaderSubsActivity.TAB_IDX_FOLLOWED_TAGS } - }) + viewModel.onBottomSheetActionClicked(ActionType.OpenSubsAtPage(subsPageIndex)) + } dialog?.setOnShowListener { dialogInterface -> val sheetDialog = dialogInterface as? BottomSheetDialog @@ -126,9 +127,15 @@ class SubfilterBottomSheetFragment : BottomSheetDialogFragment() { bottomSheet?.let { val behavior = BottomSheetBehavior.from(it) - val metrics = resources.displayMetrics + val metrics = it.context.resources.displayMetrics behavior.peekHeight = metrics.heightPixels / 2 } + + dialog?.setOnShowListener(null) + } + + viewModel.isTitleContainerVisible.observe(viewLifecycleOwner) { isVisible -> + titleContainer.isVisible = isVisible } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/adapters/ReaderBlogAdapter.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/adapters/ReaderBlogAdapter.java index db5fb935c3a8..095981ff9370 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/adapters/ReaderBlogAdapter.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/adapters/ReaderBlogAdapter.java @@ -226,8 +226,8 @@ private void toggleFollow(Context context, ReaderFollowButton followButton, Read final ActionListener listener = succeeded -> { followButton.setEnabled(true); if (!succeeded) { - int errResId = isAskingToFollow ? R.string.reader_toast_err_follow_blog - : R.string.reader_toast_err_unfollow_blog; + int errResId = isAskingToFollow ? R.string.reader_toast_err_unable_to_follow_blog + : R.string.reader_toast_err_unable_to_unfollow_blog; ToastUtils.showToast(context, errResId); followButton.setIsFollowed(!isAskingToFollow); blog.isFollowing = !isAskingToFollow; diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/adapters/ReaderPostAdapter.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/adapters/ReaderPostAdapter.java index df4131dd8cff..d18687c31e00 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/adapters/ReaderPostAdapter.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/adapters/ReaderPostAdapter.java @@ -338,8 +338,8 @@ private void toggleFollowButton( ReaderActions.ActionListener listener = succeeded -> { if (!succeeded) { - int errResId = isAskingToFollow ? R.string.reader_toast_err_add_tag - : R.string.reader_toast_err_remove_tag; + int errResId = isAskingToFollow ? R.string.reader_toast_err_adding_tag + : R.string.reader_toast_err_removing_tag; ToastUtils.showToast(context, errResId); } else { if (isAskingToFollow) { diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/adapters/ReaderTagAdapter.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/adapters/ReaderTagAdapter.java index bd52b4498bf8..5dc99a307cea 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/adapters/ReaderTagAdapter.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/adapters/ReaderTagAdapter.java @@ -9,6 +9,7 @@ import android.widget.TextView; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.recyclerview.widget.RecyclerView; import org.wordpress.android.R; @@ -93,6 +94,11 @@ public TagViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { return new TagViewHolder(view); } + @Nullable + public ReaderTagList getItems() { + return mTags; + } + @Override public void onBindViewHolder(TagViewHolder holder, int position) { final ReaderTag tag = mTags.get(position); @@ -109,7 +115,7 @@ private void performDeleteTag(@NonNull ReaderTag tag) { @Override public void onActionResult(boolean succeeded) { if (!succeeded && hasContext()) { - ToastUtils.showToast(getContext(), R.string.reader_toast_err_remove_tag); + ToastUtils.showToast(getContext(), R.string.reader_toast_err_removing_tag); refresh(); } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/ReaderCardUiState.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/ReaderCardUiState.kt index 376bda8156da..25ca10f9e81d 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/ReaderCardUiState.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/ReaderCardUiState.kt @@ -169,8 +169,8 @@ sealed class ReaderCardUiState { ) { val followContentDescription: UiStringRes by lazy { when (isFollowed) { - true -> R.string.reader_btn_unfollow - false -> R.string.reader_btn_follow + true -> R.string.reader_btn_subscribed + false -> R.string.reader_btn_subscribe }.let(::UiStringRes) } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/ReaderDiscoverFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/ReaderDiscoverFragment.kt index bea9580b298a..00918013c71e 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/ReaderDiscoverFragment.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/ReaderDiscoverFragment.kt @@ -131,9 +131,6 @@ class ReaderDiscoverFragment : ViewPagerFragment(R.layout.reader_discover_fragme when (it) { is DiscoverUiState.ContentUiState -> { (recyclerView.adapter as ReaderDiscoverAdapter).update(it.cards) - if (it.scrollToTop) { - recyclerView.scrollToPosition(0) - } } is DiscoverUiState.EmptyUiState -> { uiHelpers.setTextOrHide(actionableEmptyView.title, it.titleResId) @@ -153,9 +150,10 @@ class ReaderDiscoverFragment : ViewPagerFragment(R.layout.reader_discover_fragme ptrLayout.isEnabled = it.swipeToRefreshEnabled ptrLayout.isRefreshing = it.reloadProgressVisibility } + viewModel.scrollToTopEvent.observeEvent(viewLifecycleOwner) { recyclerView.scrollToPosition(0) } viewModel.navigationEvents.observeEvent(viewLifecycleOwner) { handleNavigation(it) } - viewModel.snackbarEvents.observeEvent(viewLifecycleOwner, { it.showSnackbar() }) - viewModel.preloadPostEvents.observeEvent(viewLifecycleOwner, { it.addWebViewCachingFragment() }) + viewModel.snackbarEvents.observeEvent(viewLifecycleOwner) { it.showSnackbar() } + viewModel.preloadPostEvents.observeEvent(viewLifecycleOwner) { it.addWebViewCachingFragment() } viewModel.start(parentViewModel) } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/ReaderDiscoverViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/ReaderDiscoverViewModel.kt index e907abe6a395..2e7c7b954b95 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/ReaderDiscoverViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/ReaderDiscoverViewModel.kt @@ -2,6 +2,7 @@ package org.wordpress.android.ui.reader.discover import androidx.lifecycle.LiveData import androidx.lifecycle.MediatorLiveData +import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Observer import androidx.lifecycle.viewModelScope import kotlinx.coroutines.CoroutineDispatcher @@ -15,6 +16,7 @@ import org.wordpress.android.models.discover.ReaderDiscoverCard.ReaderRecommende import org.wordpress.android.models.discover.ReaderDiscoverCards import org.wordpress.android.modules.IO_THREAD import org.wordpress.android.modules.UI_THREAD +import org.wordpress.android.ui.bloggingprompts.BloggingPromptsPostTagProvider.Companion.BLOGGING_PROMPT_TAG import org.wordpress.android.ui.pages.SnackbarMessageHolder import org.wordpress.android.ui.reader.ReaderTypes.ReaderPostListType.TAG_FOLLOWED import org.wordpress.android.ui.reader.discover.ReaderCardUiState.ReaderPostNewUiState @@ -79,6 +81,9 @@ class ReaderDiscoverViewModel @Inject constructor( private val _preloadPostEvents = MediatorLiveData>() val preloadPostEvents: LiveData> = _preloadPostEvents + private val _scrollToTopEvent = MutableLiveData>() + val scrollToTopEvent: LiveData> = _scrollToTopEvent + /** * Post which is about to be reblogged after the user selects a target site. */ @@ -145,7 +150,10 @@ class ReaderDiscoverViewModel @Inject constructor( _uiState.addSource(readerDiscoverDataProvider.discoverFeed) { posts -> launch { val userTags = getFollowedTagsUseCase.get() - if (userTags.isEmpty()) { + + // since new users have the dailyprompt tag followed by default, we need to ignore them when + // checking if the user has any tags followed, so we show the onboarding state (ShowNoFollowedTags) + if (userTags.filterNot { it.tagSlug == BLOGGING_PROMPT_TAG }.isEmpty()) { _uiState.value = DiscoverUiState.EmptyUiState.ShowNoFollowedTagsUiState { parentViewModel.onShowReaderInterests() } @@ -155,9 +163,11 @@ class ReaderDiscoverViewModel @Inject constructor( convertCardsToUiStates(posts), reloadProgressVisibility = false, loadMoreProgressVisibility = false, - scrollToTop = swipeToRefreshTriggered ) - swipeToRefreshTriggered = false + if (swipeToRefreshTriggered) { + _scrollToTopEvent.postValue(Event(Unit)) + swipeToRefreshTriggered = false + } } else { _uiState.value = DiscoverUiState.EmptyUiState.ShowNoPostsUiState { _navigationEvents.value = Event(ShowReaderSubs) @@ -523,7 +533,6 @@ class ReaderDiscoverViewModel @Inject constructor( val fullscreenProgressVisibility: Boolean = false, val swipeToRefreshEnabled: Boolean = false, open val fullscreenEmptyVisibility: Boolean = false, - open val scrollToTop: Boolean = false ) { open val reloadProgressVisibility: Boolean = false open val loadMoreProgressVisibility: Boolean = false @@ -532,7 +541,6 @@ class ReaderDiscoverViewModel @Inject constructor( val cards: List, override val reloadProgressVisibility: Boolean, override val loadMoreProgressVisibility: Boolean, - override val scrollToTop: Boolean ) : DiscoverUiState(contentVisiblity = true, swipeToRefreshEnabled = true) object LoadingUiState : DiscoverUiState(fullscreenProgressVisibility = true) @@ -552,15 +560,15 @@ class ReaderDiscoverViewModel @Inject constructor( data class ShowNoFollowedTagsUiState(override val action: () -> Unit) : EmptyUiState() { override val titleResId = R.string.reader_discover_empty_title - override val subTitleRes = R.string.reader_discover_empty_subtitle + override val subTitleRes = R.string.reader_discover_empty_subtitle_subscribe override val buttonResId = R.string.reader_discover_empty_button_text } data class ShowNoPostsUiState(override val action: () -> Unit) : EmptyUiState() { override val titleResId = R.string.reader_discover_no_posts_title - override val buttonResId = R.string.reader_discover_no_posts_button_text - override val subTitleRes = R.string.reader_discover_no_posts_subtitle - override val illustrationResId = R.drawable.img_illustration_empty_results_216dp + override val buttonResId = R.string.reader_discover_no_posts_button_tags_text + override val subTitleRes = R.string.reader_discover_no_posts_subscribe_subtitle + override val illustrationResId = R.drawable.illustration_reader_empty } } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/ReaderPostCardActionsHandler.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/ReaderPostCardActionsHandler.kt index 951eb7f6c678..c96e0ef86858 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/ReaderPostCardActionsHandler.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/ReaderPostCardActionsHandler.kt @@ -405,7 +405,7 @@ class ReaderPostCardActionsHandler @Inject constructor( _snackbarEvents.postValue( Event( SnackbarMessageHolder( - UiStringRes(R.string.reader_toast_blog_blocked), + UiStringRes(R.string.reader_toast_posts_from_this_blog_blocked), UiStringRes(R.string.undo), { coroutineScope.launch { @@ -419,13 +419,13 @@ class ReaderPostCardActionsHandler @Inject constructor( BlockSiteState.Success, BlockSiteState.Failed.AlreadyRunning -> Unit // do nothing BlockSiteState.Failed.NoNetwork -> { _snackbarEvents.postValue( - Event(SnackbarMessageHolder(UiStringRes(R.string.reader_toast_err_block_blog))) + Event(SnackbarMessageHolder(UiStringRes(R.string.reader_toast_err_unable_to_block_blog))) ) } BlockSiteState.Failed.RequestFailed -> { _refreshPosts.postValue(Event(Unit)) _snackbarEvents.postValue( - Event(SnackbarMessageHolder(UiStringRes(R.string.reader_toast_err_block_blog))) + Event(SnackbarMessageHolder(UiStringRes(R.string.reader_toast_err_unable_to_block_blog))) ) } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/ReaderPostMoreButtonUiStateBuilder.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/ReaderPostMoreButtonUiStateBuilder.kt index e3fcc2c5b7fd..4af5cfe018c6 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/ReaderPostMoreButtonUiStateBuilder.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/ReaderPostMoreButtonUiStateBuilder.kt @@ -103,7 +103,7 @@ class ReaderPostMoreButtonUiStateBuilder @Inject constructor( if (isNotificationsEnabled) { SecondaryAction( type = SITE_NOTIFICATIONS, - label = UiStringRes(R.string.reader_btn_notifications_off), + label = UiStringRes(R.string.reader_btn_blog_notifications_off), labelColor = R.attr.wpColorOnSurfaceMedium, iconRes = R.drawable.ic_reader_bell_24dp, isSelected = true, @@ -112,7 +112,7 @@ class ReaderPostMoreButtonUiStateBuilder @Inject constructor( } else { SecondaryAction( type = SITE_NOTIFICATIONS, - label = UiStringRes(R.string.reader_btn_notifications_on), + label = UiStringRes(R.string.reader_btn_blog_notifications_on), labelColor = MaterialR.attr.colorOnSurface, iconRes = R.drawable.ic_reader_bell_24dp, iconColor = R.attr.wpColorOnSurfaceMedium, @@ -196,7 +196,7 @@ class ReaderPostMoreButtonUiStateBuilder @Inject constructor( if (isPostFollowed) { SecondaryAction( type = FOLLOW, - label = UiStringRes(R.string.reader_btn_unfollow), + label = UiStringRes(R.string.reader_btn_subscribed), labelColor = R.attr.wpColorOnSurfaceMedium, iconRes = R.drawable.ic_reader_following_white_24dp, isSelected = true, @@ -205,7 +205,7 @@ class ReaderPostMoreButtonUiStateBuilder @Inject constructor( } else { SecondaryAction( type = FOLLOW, - label = UiStringRes(R.string.reader_btn_follow), + label = UiStringRes(R.string.reader_btn_subscribe), labelColor = MaterialR.attr.colorSecondary, iconRes = R.drawable.ic_reader_follow_white_24dp, isSelected = false, @@ -216,7 +216,7 @@ class ReaderPostMoreButtonUiStateBuilder @Inject constructor( private fun buildBlockSite(onButtonClicked: (Long, Long, ReaderPostCardActionType) -> Unit) = SecondaryAction( type = BLOCK_SITE, - label = UiStringRes(R.string.reader_menu_block_blog), + label = UiStringRes(R.string.reader_menu_block_this_blog), labelColor = R.attr.wpColorError, iconRes = R.drawable.ic_block_white_24dp, iconColor = R.attr.wpColorError, diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/interests/ReaderInterestsActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/interests/ReaderInterestsActivity.kt index cf7787ea9b7e..1259e9ccf0db 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/interests/ReaderInterestsActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/interests/ReaderInterestsActivity.kt @@ -18,7 +18,7 @@ class ReaderInterestsActivity : LocaleAwareActivity() { supportActionBar?.let { it.setHomeButtonEnabled(true) it.setDisplayHomeAsUpEnabled(true) - it.title = getString(R.string.reader_title_interests) + it.title = getString(R.string.reader_title_tags) } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/interests/ReaderInterestsFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/interests/ReaderInterestsFragment.kt index 29415dce9f88..cad0696f0433 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/interests/ReaderInterestsFragment.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/interests/ReaderInterestsFragment.kt @@ -180,6 +180,6 @@ class ReaderInterestsFragment : Fragment(R.layout.reader_interests_fragment_layo enum class EntryPoint { DISCOVER, - SETTINGS + SETTINGS, } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/interests/ReaderInterestsViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/interests/ReaderInterestsViewModel.kt index fc06dca994c3..b2a754a06137 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/interests/ReaderInterestsViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/discover/interests/ReaderInterestsViewModel.kt @@ -11,6 +11,7 @@ import org.wordpress.android.R import org.wordpress.android.analytics.AnalyticsTracker import org.wordpress.android.models.ReaderTag import org.wordpress.android.models.ReaderTagList +import org.wordpress.android.ui.bloggingprompts.BloggingPromptsPostTagProvider.Companion.BLOGGING_PROMPT_TAG import org.wordpress.android.ui.pages.SnackbarMessageHolder import org.wordpress.android.ui.reader.discover.interests.ReaderInterestsFragment.EntryPoint import org.wordpress.android.ui.reader.discover.interests.ReaderInterestsViewModel.DoneButtonUiState.DoneButtonDisabledUiState @@ -92,7 +93,9 @@ class ReaderInterestsViewModel @Inject constructor( } private fun checkAndLoadInterests(userTags: ReaderTagList) { - if (userTags.isEmpty()) { + // since new users have the dailyprompt tag followed by default, we need to ignore them when + // checking if the user has any tags followed, so we show the onboarding state (ShowNoFollowedTags) + if (userTags.filterNot { it.tagSlug == BLOGGING_PROMPT_TAG }.isEmpty()) { loadInterests(userTags) } else { parentViewModel?.onCloseReaderInterests() @@ -165,8 +168,6 @@ class ReaderInterestsViewModel @Inject constructor( ) ) ) - - parentViewModel?.completeQuickStartFollowSiteTaskIfNeeded() } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/services/discover/ReaderDiscoverLogic.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/services/discover/ReaderDiscoverLogic.kt index b8f609c866ca..17a833495dcf 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/services/discover/ReaderDiscoverLogic.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/services/discover/ReaderDiscoverLogic.kt @@ -139,7 +139,7 @@ class ReaderDiscoverLogic @Inject constructor( insertBlogsIntoDb(cards.filterIsInstance().map { it.blogs }.flatten()) // Simplify the json. The simplified version is used in the upper layers to load the data from the db. - val simplifiedCardsJson = createSimplifiedJson(fullCardsJson) + val simplifiedCardsJson = createSimplifiedJson(fullCardsJson, taskType) insertCardsJsonIntoDb(simplifiedCardsJson) val nextPageHandle = parseDiscoverCardsJsonUseCase.parseNextPageHandle(json) @@ -198,7 +198,7 @@ class ReaderDiscoverLogic @Inject constructor( * as it's already stored in the db. */ @Suppress("NestedBlockDepth") - private fun createSimplifiedJson(cardsJsonArray: JSONArray): JSONArray { + private fun createSimplifiedJson(cardsJsonArray: JSONArray, discoverTasks: DiscoverTasks): JSONArray { var index = 0 val simplifiedJson = JSONArray() for (i in 0 until cardsJsonArray.length()) { @@ -212,6 +212,10 @@ class ReaderDiscoverLogic @Inject constructor( } } JSON_CARD_INTERESTS_YOU_MAY_LIKE -> { + // We should not have an interests/tags card as the first element on Discover feed. + if (i == 0 && discoverTasks == REQUEST_FIRST_PAGE) { + continue + } simplifiedJson.put(index++, cardJson) } JSON_CARD_POST -> { diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/services/update/ReaderUpdateLogic.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/services/update/ReaderUpdateLogic.java index 911fff9bb797..a9af93b20e15 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/services/update/ReaderUpdateLogic.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/services/update/ReaderUpdateLogic.java @@ -125,7 +125,7 @@ private boolean displayNameUpdateWasNeeded(ReaderTagList serverTopics) { for (ReaderTag tag : serverTopics) { String tagNameBefore = tag.getTagDisplayName(); if (tag.isFollowedSites()) { - tag.setTagDisplayName(mContext.getString(R.string.reader_following_display_name)); + tag.setTagDisplayName(mContext.getString(R.string.reader_subscribed_display_name)); if (!tagNameBefore.equals(tag.getTagDisplayName())) updateDone = true; } else if (tag.isDiscover()) { tag.setTagDisplayName(mContext.getString(R.string.reader_discover_display_name)); diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/subfilter/BottomSheetPageEmptyUiState.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/subfilter/BottomSheetPageEmptyUiState.kt index 273b2802c8c3..18ec49e917fb 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/subfilter/BottomSheetPageEmptyUiState.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/subfilter/BottomSheetPageEmptyUiState.kt @@ -1,21 +1,31 @@ package org.wordpress.android.ui.reader.subfilter -import org.wordpress.android.ui.utils.UiString.UiStringRes +import org.wordpress.android.ui.utils.UiString sealed class SubfilterBottomSheetEmptyUiState { object HiddenEmptyUiState : SubfilterBottomSheetEmptyUiState() data class VisibleEmptyUiState( - val title: UiStringRes, - val buttonText: UiStringRes, - val action: ActionType - ) : SubfilterBottomSheetEmptyUiState() + val title: UiString? = null, + val text: UiString, + val primaryButton: Button? = null, + val secondaryButton: Button? = null + ) : SubfilterBottomSheetEmptyUiState() { + data class Button( + val text: UiString, + val action: ActionType, + ) + } } -sealed class ActionType { +sealed interface ActionType { data class OpenSubsAtPage( val tabIndex: Int - ) : ActionType() + ) : ActionType - object OpenLoginPage : ActionType() + data object OpenLoginPage : ActionType + + data object OpenSearchPage : ActionType + + data object OpenSuggestedTagsPage : ActionType } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/subfilter/SubFilterViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/subfilter/SubFilterViewModel.kt index f4464202f3ea..1a9cda7734b1 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/subfilter/SubFilterViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/subfilter/SubFilterViewModel.kt @@ -1,6 +1,5 @@ package org.wordpress.android.ui.reader.subfilter -import android.annotation.SuppressLint import android.os.Bundle import androidx.annotation.VisibleForTesting import androidx.lifecycle.LiveData @@ -9,7 +8,6 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode -import org.wordpress.android.R import org.wordpress.android.analytics.AnalyticsTracker.Stat import org.wordpress.android.datasets.ReaderBlogTable import org.wordpress.android.datasets.ReaderTagTable @@ -17,15 +15,12 @@ import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.models.ReaderTag import org.wordpress.android.modules.BG_THREAD import org.wordpress.android.modules.UI_THREAD -import org.wordpress.android.ui.Organization.NO_ORGANIZATION import org.wordpress.android.ui.prefs.AppPrefsWrapper import org.wordpress.android.ui.reader.ReaderEvents import org.wordpress.android.ui.reader.ReaderTypes.ReaderPostListType import org.wordpress.android.ui.reader.services.update.ReaderUpdateLogic.UpdateTask import org.wordpress.android.ui.reader.subfilter.BottomSheetUiState.BottomSheetHidden import org.wordpress.android.ui.reader.subfilter.BottomSheetUiState.BottomSheetVisible -import org.wordpress.android.ui.reader.subfilter.SubfilterCategory.SITES -import org.wordpress.android.ui.reader.subfilter.SubfilterCategory.TAGS import org.wordpress.android.ui.reader.subfilter.SubfilterListItem.Site import org.wordpress.android.ui.reader.subfilter.SubfilterListItem.SiteAll import org.wordpress.android.ui.reader.subfilter.SubfilterListItem.Tag @@ -34,7 +29,6 @@ import org.wordpress.android.ui.reader.tracker.ReaderTrackerType import org.wordpress.android.ui.reader.utils.ReaderUtils import org.wordpress.android.ui.reader.viewmodels.ReaderModeInfo import org.wordpress.android.ui.utils.UiString.UiStringRes -import org.wordpress.android.ui.utils.UiString.UiStringText import org.wordpress.android.util.AppLog import org.wordpress.android.util.AppLog.T import org.wordpress.android.util.EventBusWrapper @@ -66,15 +60,15 @@ class SubFilterViewModel @Inject constructor( private val _bottomSheetUiState = MutableLiveData>() val bottomSheetUiState: LiveData> = _bottomSheetUiState - private val _filtersMatchCount = MutableLiveData>() - val filtersMatchCount: LiveData> = _filtersMatchCount - - private val _bottomSheetEmptyViewAction = MutableLiveData>() - val bottomSheetEmptyViewAction: LiveData> = _bottomSheetEmptyViewAction + private val _bottomSheetAction = MutableLiveData>() + val bottomSheetAction: LiveData> = _bottomSheetAction private val _updateTagsAndSites = MutableLiveData>>() val updateTagsAndSites: LiveData>> = _updateTagsAndSites + private val _isTitleContainerVisible = MutableLiveData(true) + val isTitleContainerVisible: LiveData = _isTitleContainerVisible + private var lastKnownUserId: Long? = null private var lastTokenAvailableStatus: Boolean? = null @@ -114,8 +108,6 @@ class SubFilterViewModel @Inject constructor( updateSubfilter(currentSubfilter ?: getCurrentSubfilterValue()) initSubfiltersTracking(tag.isFilterable) } - - _filtersMatchCount.value = hashMapOf() } fun loadSubFilters() { @@ -204,28 +196,41 @@ class SubFilterViewModel @Inject constructor( ) } - fun setDefaultSubfilter() { + fun setDefaultSubfilter(isClearingFilter: Boolean) { + readerTracker.track(Stat.READER_FILTER_SHEET_CLEARED) updateSubfilter( - SiteAll( + filter = SiteAll( onClickAction = ::onSubfilterClicked, - isSelected = true + isSelected = true, + isClearingFilter = isClearingFilter, + ), + ) + } + + fun onSubFiltersListButtonClicked( + category: SubfilterCategory, + ) { + updateTagsAndSites() + _bottomSheetUiState.value = Event( + BottomSheetVisible( + UiStringRes(category.titleRes), + listOf(category) // TODO thomashortadev this should accept only a single category ) ) + val source = when(category) { + SubfilterCategory.SITES -> "blogs" + SubfilterCategory.TAGS -> "tags" + } + readerTracker.track(Stat.READER_FILTER_SHEET_DISPLAYED, source) } - fun onSubFiltersListButtonClicked() { + fun updateTagsAndSites() { _updateTagsAndSites.value = Event( EnumSet.of( UpdateTask.TAGS, UpdateTask.FOLLOWED_BLOGS ) ) - _bottomSheetUiState.value = Event(BottomSheetVisible( - mTagFragmentStartedWith?.let { - UiStringText(it.label) - } ?: UiStringRes(R.string.reader_filter_main_title), - if (mTagFragmentStartedWith?.organization == NO_ORGANIZATION) listOf(SITES, TAGS) else listOf(SITES) - )) } fun onBottomSheetCancelled() { @@ -243,6 +248,7 @@ class SubFilterViewModel @Inject constructor( SubfilterListItem.ItemType.DIVIDER -> { // nop } + SubfilterListItem.ItemType.SITE_ALL -> _readerModeInfo.value = (ReaderModeInfo( streamTag ?: ReaderUtils.getDefaultTag(), ReaderPostListType.TAG_FOLLOWED, @@ -253,6 +259,7 @@ class SubFilterViewModel @Inject constructor( isFirstLoad, false )) + SubfilterListItem.ItemType.SITE -> { val currentFeedId = (subfilterListItem as Site).blog.feedId val currentBlogId = if (subfilterListItem.blog.hasFeedUrl()) { @@ -272,6 +279,7 @@ class SubFilterViewModel @Inject constructor( true )) } + SubfilterListItem.ItemType.TAG -> _readerModeInfo.value = (ReaderModeInfo( (subfilterListItem as Tag).tag, ReaderPostListType.TAG_FOLLOWED, @@ -287,7 +295,10 @@ class SubFilterViewModel @Inject constructor( } fun onSubfilterSelected(subfilterListItem: SubfilterListItem) { - readerTracker.track(Stat.READER_FILTER_SHEET_ITEM_SELECTED) + // We should not track subfilter selected if we're clearing a filter that is currently applied. + if (!subfilterListItem.isClearingFilter) { + readerTracker.track(Stat.READER_FILTER_SHEET_ITEM_SELECTED) + } changeSubfilter(subfilterListItem, true, mTagFragmentStartedWith) } @@ -295,16 +306,9 @@ class SubFilterViewModel @Inject constructor( changeSubfilter(getCurrentSubfilterValue(), false, mTagFragmentStartedWith) } - @SuppressLint("NullSafeMutableLiveData") - fun onSubfilterPageUpdated(category: SubfilterCategory, count: Int) { - val currentValue = _filtersMatchCount.value - currentValue?.put(category, count) - _filtersMatchCount.postValue(currentValue) - } - fun onBottomSheetActionClicked(action: ActionType) { _bottomSheetUiState.postValue(Event(BottomSheetHidden)) - _bottomSheetEmptyViewAction.postValue(Event(action)) + _bottomSheetAction.postValue(Event(action)) } private fun updateSubfilter(filter: SubfilterListItem) { @@ -341,9 +345,14 @@ class SubFilterViewModel @Inject constructor( } if (userIdChanged || accessTokenStatusChanged) { - _updateTagsAndSites.value = Event(EnumSet.of(UpdateTask.TAGS)) + _updateTagsAndSites.value = Event( + EnumSet.of( + UpdateTask.TAGS, + UpdateTask.FOLLOWED_BLOGS + ) + ) - setDefaultSubfilter() + setDefaultSubfilter(false) } } @@ -358,6 +367,10 @@ class SubFilterViewModel @Inject constructor( outState.putBoolean(ARG_IS_FIRST_LOAD, isFirstLoad) } + fun setTitleContainerVisibility(isVisible: Boolean) { + _isTitleContainerVisible.value = isVisible + } + @Suppress("unused", "UNUSED_PARAMETER") @Subscribe(threadMode = ThreadMode.MAIN) fun onEventMainThread(event: ReaderEvents.FollowedTagsChanged) { @@ -384,5 +397,10 @@ class SubFilterViewModel @Inject constructor( const val ARG_IS_FIRST_LOAD = "is_first_load" const val TRACK_TAB = "tab" + + @JvmStatic + fun getViewModelKeyForTag(tag: ReaderTag): String { + return SUBFILTER_VM_BASE_KEY + tag.keyString + } } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/subfilter/SubfilterListItem.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/subfilter/SubfilterListItem.kt index 0827c9cd121e..1e24f2c868d4 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/subfilter/SubfilterListItem.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/subfilter/SubfilterListItem.kt @@ -14,6 +14,7 @@ import org.wordpress.android.ui.utils.UiString.UiStringText sealed class SubfilterListItem(val type: ItemType, val isTrackedItem: Boolean = false) { open var isSelected: Boolean = false + open var isClearingFilter: Boolean = false open val onClickAction: ((filter: SubfilterListItem) -> Unit)? = null open val label: UiString? = null @@ -52,6 +53,7 @@ sealed class SubfilterListItem(val type: ItemType, val isTrackedItem: Boolean = @Suppress("DataClassShouldBeImmutable") data class SiteAll( override var isSelected: Boolean = false, + override var isClearingFilter: Boolean = false, override val onClickAction: (filter: SubfilterListItem) -> Unit ) : SubfilterListItem(SITE_ALL) { override val label: UiString = UiStringRes(R.string.reader_filter_cta) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/subfilter/SubfilterPageFragment.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/subfilter/SubfilterPageFragment.kt index d95e8c22407e..7e7d1bd07c77 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/subfilter/SubfilterPageFragment.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/subfilter/SubfilterPageFragment.kt @@ -35,6 +35,7 @@ import org.wordpress.android.ui.stats.refresh.utils.StatsUtils import org.wordpress.android.ui.utils.UiHelpers import org.wordpress.android.util.config.SeenUnseenWithCounterFeatureConfig import org.wordpress.android.util.extensions.getSerializableCompat +import org.wordpress.android.util.image.ImageManager import org.wordpress.android.widgets.WPTextView import java.lang.ref.WeakReference import javax.inject.Inject @@ -47,6 +48,9 @@ class SubfilterPageFragment : Fragment() { @Inject lateinit var uiHelpers: UiHelpers + @Inject + lateinit var imageManager: ImageManager + @Inject lateinit var seenUnseenWithCounterFeatureConfig: SeenUnseenWithCounterFeatureConfig @@ -58,7 +62,9 @@ class SubfilterPageFragment : Fragment() { private lateinit var recyclerView: RecyclerView private lateinit var emptyStateContainer: LinearLayout private lateinit var title: WPTextView - private lateinit var actionButton: Button + private lateinit var text: WPTextView + private lateinit var primaryButton: Button + private lateinit var secondaryButton: Button companion object { const val CATEGORY_KEY = "category_key" @@ -89,11 +95,14 @@ class SubfilterPageFragment : Fragment() { recyclerView = view.findViewById(R.id.content_recycler_view) recyclerView.layoutManager = LinearLayoutManager(requireActivity()) - recyclerView.adapter = SubfilterListAdapter(uiHelpers, statsUtils, seenUnseenWithCounterFeatureConfig) + recyclerView.adapter = + SubfilterListAdapter(uiHelpers, statsUtils, imageManager, seenUnseenWithCounterFeatureConfig) emptyStateContainer = view.findViewById(R.id.empty_state_container) title = emptyStateContainer.findViewById(R.id.title) - actionButton = emptyStateContainer.findViewById(R.id.action_button) + text = emptyStateContainer.findViewById(R.id.text) + primaryButton = emptyStateContainer.findViewById(R.id.action_button_primary) + secondaryButton = emptyStateContainer.findViewById(R.id.action_button_secondary) subFilterViewModel = ViewModelProvider( requireParentFragment().parentFragment as ViewModelStoreOwner, @@ -115,27 +124,58 @@ class SubfilterPageFragment : Fragment() { viewModel.onSubFiltersChanged(items.isEmpty()) adapter.update(items) - subFilterViewModel.onSubfilterPageUpdated(category, items.size) } } viewModel.emptyState.observe(viewLifecycleOwner) { uiState -> if (isAdded) { when (uiState) { - HiddenEmptyUiState -> emptyStateContainer.visibility = View.GONE - is VisibleEmptyUiState -> { - emptyStateContainer.visibility = View.VISIBLE - title.setText(uiState.title.stringRes) - actionButton.setText(uiState.buttonText.stringRes) - actionButton.setOnClickListener { - subFilterViewModel.onBottomSheetActionClicked(uiState.action) - } - } + HiddenEmptyUiState -> hideEmptyUi() + is VisibleEmptyUiState -> showEmptyUi(uiState) } } } } + private fun hideEmptyUi() { + emptyStateContainer.visibility = View.GONE + subFilterViewModel.setTitleContainerVisibility(isVisible = true) + } + + private fun showEmptyUi(uiState: VisibleEmptyUiState) { + emptyStateContainer.visibility = View.VISIBLE + subFilterViewModel.setTitleContainerVisibility(isVisible = false) + + if (uiState.title == null) { + title.visibility = View.GONE + } else { + title.visibility = View.VISIBLE + title.text = uiHelpers.getTextOfUiString(requireContext(), uiState.title) + } + + text.text = uiHelpers.getTextOfUiString(requireContext(), uiState.text) + + if (uiState.primaryButton == null) { + primaryButton.visibility = View.GONE + } else { + primaryButton.visibility = View.VISIBLE + primaryButton.text = uiHelpers.getTextOfUiString(requireContext(), uiState.primaryButton.text) + primaryButton.setOnClickListener { + subFilterViewModel.onBottomSheetActionClicked(uiState.primaryButton.action) + } + } + + if (uiState.secondaryButton == null) { + secondaryButton.visibility = View.GONE + } else { + secondaryButton.visibility = View.VISIBLE + secondaryButton.text = uiHelpers.getTextOfUiString(requireContext(), uiState.secondaryButton.text) + secondaryButton.setOnClickListener { + subFilterViewModel.onBottomSheetActionClicked(uiState.secondaryButton.action) + } + } + } + fun setNestedScrollBehavior(enable: Boolean) { if (!isAdded) return @@ -176,8 +216,8 @@ class SubfilterPagerAdapter( } enum class SubfilterCategory(@StringRes val titleRes: Int, val type: ItemType) : Parcelable { - SITES(R.string.reader_filter_sites_title, SITE), - TAGS(R.string.reader_filter_tags_title, TAG); + SITES(R.string.reader_filter_by_blog_title, SITE), + TAGS(R.string.reader_filter_by_tag_title, TAG); override fun writeToParcel(parcel: Parcel, flags: Int) { parcel.writeInt(type.ordinal) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/subfilter/adapters/SubfilterListAdapter.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/subfilter/adapters/SubfilterListAdapter.kt index 8e15b3570aac..dfebfbc11c96 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/subfilter/adapters/SubfilterListAdapter.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/subfilter/adapters/SubfilterListAdapter.kt @@ -24,10 +24,12 @@ import org.wordpress.android.ui.reader.subfilter.viewholders.TagViewHolder import org.wordpress.android.ui.stats.refresh.utils.StatsUtils import org.wordpress.android.ui.utils.UiHelpers import org.wordpress.android.util.config.SeenUnseenWithCounterFeatureConfig +import org.wordpress.android.util.image.ImageManager class SubfilterListAdapter( val uiHelpers: UiHelpers, val statsUtils: StatsUtils, + val imageManager: ImageManager, val seenUnseenWithCounterFeatureConfig: SeenUnseenWithCounterFeatureConfig ) : Adapter() { private var items: List = listOf() @@ -52,6 +54,7 @@ class SubfilterListAdapter( is SiteViewHolder -> holder.bind( item as Site, uiHelpers, + imageManager, statsUtils, seenUnseenWithCounterFeatureConfig.isEnabled() ) diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/subfilter/viewholders/SiteViewHolder.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/subfilter/viewholders/SiteViewHolder.kt index 695f59d4a01a..e55426c38b83 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/subfilter/viewholders/SiteViewHolder.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/subfilter/viewholders/SiteViewHolder.kt @@ -2,13 +2,18 @@ package org.wordpress.android.ui.reader.subfilter.viewholders import android.view.View import android.view.ViewGroup +import android.widget.ImageView import android.widget.TextView +import androidx.core.view.isVisible import org.wordpress.android.R +import org.wordpress.android.models.ReaderBlog import org.wordpress.android.ui.reader.subfilter.SubfilterListItem.Site import org.wordpress.android.ui.stats.refresh.utils.ONE_THOUSAND import org.wordpress.android.ui.stats.refresh.utils.StatsUtils import org.wordpress.android.ui.utils.UiHelpers import org.wordpress.android.util.UrlUtils +import org.wordpress.android.util.image.ImageManager +import org.wordpress.android.util.image.ImageType class SiteViewHolder( parent: ViewGroup @@ -16,8 +21,15 @@ class SiteViewHolder( private val itemTitle = itemView.findViewById(R.id.item_title) private val itemUrl = itemView.findViewById(R.id.item_url) private val itemUnseenCount = itemView.findViewById(R.id.unseen_count) + private val itemAvatar = itemView.findViewById(R.id.item_avatar) - fun bind(site: Site, uiHelpers: UiHelpers, statsUtils: StatsUtils, showUnreadpostsCount: Boolean) { + fun bind( + site: Site, + uiHelpers: UiHelpers, + imageManager: ImageManager, + statsUtils: StatsUtils, + showUnreadpostsCount: Boolean + ) { super.bind(site, uiHelpers) this.itemTitle.text = uiHelpers.getTextOfUiString(parent.context, site.label) this.itemUrl.visibility = View.VISIBLE @@ -36,5 +48,21 @@ class SiteViewHolder( } else { this.itemUnseenCount.visibility = View.GONE } + + updateSiteAvatar(blog, imageManager) + } + + private fun updateSiteAvatar(blog: ReaderBlog, imageManager: ImageManager) { + itemAvatar.isVisible = true + if (blog.hasImageUrl()) { + imageManager.loadIntoCircle( + itemAvatar, + ImageType.BLAVATAR_CIRCULAR, + blog.imageUrl, + ) + } else { + imageManager.cancelRequestAndClearImageView(itemAvatar) + itemAvatar.setImageResource(R.drawable.bg_oval_placeholder_globe_no_border_24dp) + } } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/tracker/ReaderTracker.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/tracker/ReaderTracker.kt index 4a528e534147..b40dfc0380ee 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/tracker/ReaderTracker.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/tracker/ReaderTracker.kt @@ -376,6 +376,28 @@ class ReaderTracker @Inject constructor( analyticsUtilsWrapper.trackRailcarRender(railcarJson) } + fun trackDropdownMenuOpened() { + analyticsTrackerWrapper.track(AnalyticsTracker.Stat.READER_DROPDOWN_MENU_OPENED) + } + + fun trackDropdownMenuItemTapped(readerTag: ReaderTag) { + when { + readerTag.isDiscover -> "discover" + readerTag.isFollowedSites -> "following" + readerTag.isBookmarked -> "saved" + readerTag.isPostsILike -> "liked" + readerTag.isA8C -> "a8c" + readerTag.isListTopic -> "list" + readerTag.isP2 -> "p2" + else -> null + }?.let { trackingId -> + analyticsTrackerWrapper.track( + stat = AnalyticsTracker.Stat.READER_DROPDOWN_MENU_ITEM_TAPPED, + properties = mapOf("id" to trackingId) + ) + } + } + /* HELPER */ @JvmOverloads diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/usecases/LoadReaderTabsUseCase.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/usecases/LoadReaderTabsUseCase.kt index bc70bef3c116..e6d6aea13e5c 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/usecases/LoadReaderTabsUseCase.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/usecases/LoadReaderTabsUseCase.kt @@ -5,6 +5,7 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext import org.wordpress.android.datasets.ReaderTagTable import org.wordpress.android.models.ReaderTagList +import org.wordpress.android.models.containsFollowingTag import org.wordpress.android.modules.BG_THREAD import org.wordpress.android.ui.reader.utils.ReaderUtils import org.wordpress.android.ui.reader.utils.ReaderUtilsWrapper diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/utils/ReaderTopBarMenuHelper.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/utils/ReaderTopBarMenuHelper.kt new file mode 100644 index 000000000000..26e2f876a83c --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/utils/ReaderTopBarMenuHelper.kt @@ -0,0 +1,123 @@ +package org.wordpress.android.ui.reader.utils + +import androidx.collection.SparseArrayCompat +import androidx.collection.forEach +import androidx.collection.isNotEmpty +import androidx.collection.set +import org.wordpress.android.R +import org.wordpress.android.models.ReaderTag +import org.wordpress.android.models.ReaderTagList +import org.wordpress.android.models.ReaderTagType +import org.wordpress.android.ui.compose.components.menu.dropdown.MenuElementData +import org.wordpress.android.ui.utils.UiString +import org.wordpress.android.util.extensions.indexOrNull +import javax.inject.Inject + +class ReaderTopBarMenuHelper @Inject constructor() { + fun createMenu(readerTagsList: ReaderTagList): List { + return mutableListOf().apply { + readerTagsList.indexOrNull { it.isDiscover }?.let { discoverIndex -> + add(createDiscoverItem(getMenuItemIdFromReaderTagIndex(discoverIndex))) + } + readerTagsList.indexOrNull { it.isFollowedSites }?.let { followingIndex -> + add(createSubscriptionsItem(getMenuItemIdFromReaderTagIndex(followingIndex))) + } + readerTagsList.indexOrNull { it.isBookmarked }?.let { savedIndex -> + add(createSavedItem(getMenuItemIdFromReaderTagIndex(savedIndex))) + } + readerTagsList.indexOrNull { it.isPostsILike }?.let { likedIndex -> + add(createLikedItem(getMenuItemIdFromReaderTagIndex(likedIndex))) + } + readerTagsList.indexOrNull { it.isA8C }?.let { a8cIndex -> + add(createAutomatticItem(getMenuItemIdFromReaderTagIndex(a8cIndex))) + } + readerTagsList.indexOrNull { it.isP2 }?.let { followedP2sIndex -> + add(createFollowedP2sItem( + id = getMenuItemIdFromReaderTagIndex(followedP2sIndex), + text = readerTagsList[followedP2sIndex].tagTitle, + )) + } + readerTagsList + .foldIndexed(SparseArrayCompat()) { index, sparseArray, readerTag -> + if (readerTag.tagType == ReaderTagType.CUSTOM_LIST) { + sparseArray[index] = readerTag + } + sparseArray + } + .takeIf { it.isNotEmpty() } + ?.let { customListsArray -> + add(MenuElementData.Divider) + add(createCustomListsItem(customListsArray)) + } + } + } + + private fun createDiscoverItem(id: String): MenuElementData.Item.Single { + return MenuElementData.Item.Single( + id = id, + text = UiString.UiStringRes(R.string.reader_dropdown_menu_discover), + leadingIcon = R.drawable.ic_reader_discover_24dp, + ) + } + + private fun createSubscriptionsItem(id: String): MenuElementData.Item.Single { + return MenuElementData.Item.Single( + id = id, + text = UiString.UiStringRes(R.string.reader_dropdown_menu_subscriptions), + leadingIcon = R.drawable.ic_reader_subscriptions_24dp, + ) + } + + private fun createSavedItem(id: String): MenuElementData.Item.Single { + return MenuElementData.Item.Single( + id = id, + text = UiString.UiStringRes(R.string.reader_dropdown_menu_saved), + leadingIcon = R.drawable.ic_reader_saved_24dp, + ) + } + + private fun createLikedItem(id: String): MenuElementData.Item.Single { + return MenuElementData.Item.Single( + id = id, + text = UiString.UiStringRes(R.string.reader_dropdown_menu_liked), + leadingIcon = R.drawable.ic_reader_liked_24dp, + ) + } + + private fun createAutomatticItem(id: String): MenuElementData.Item.Single { + return MenuElementData.Item.Single( + id = id, + text = UiString.UiStringRes(R.string.reader_dropdown_menu_automattic), + ) + } + + private fun createFollowedP2sItem(id: String, text: String): MenuElementData.Item.Single { + return MenuElementData.Item.Single( + id = id, + text = UiString.UiStringText(text), + ) + } + + private fun createCustomListsItem(customLists: SparseArrayCompat): MenuElementData.Item.SubMenu { + val customListsMenuItems = mutableListOf() + customLists.forEach { index, readerTag -> + customListsMenuItems.add( + MenuElementData.Item.Single( + id = getMenuItemIdFromReaderTagIndex(index), + text = UiString.UiStringText(readerTag.tagTitle), + ) + ) + } + return MenuElementData.Item.SubMenu( + // We don't need this ID since this menu item just opens the sub-menu. It doesn't + // change the content that is currently being displayed. + id = "custom-lists", + text = UiString.UiStringRes(R.string.reader_dropdown_menu_lists), + children = customListsMenuItems, + ) + } + + private fun getMenuItemIdFromReaderTagIndex(readerTagIndex: Int): String = "$readerTagIndex" + + fun getReaderTagIndexFromMenuItem(menuItem: MenuElementData.Item.Single) = menuItem.id.toInt() +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/utils/ReaderUtils.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/utils/ReaderUtils.java index 506747e0518c..3090e254e266 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/utils/ReaderUtils.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/utils/ReaderUtils.java @@ -332,8 +332,8 @@ public static ReaderTag getDefaultTag() { if (tag.isDefaultInMemoryTag()) { // if the tag was created in memory from createTagFromTagName // we need to set some fields as below before to use it - tag.setTagTitle(context.getString(R.string.reader_following_display_name)); - tag.setTagDisplayName(context.getString(R.string.reader_following_display_name)); + tag.setTagTitle(context.getString(R.string.reader_subscribed_display_name)); + tag.setTagDisplayName(context.getString(R.string.reader_subscribed_display_name)); String baseUrl = clientUtilsProvider.getTagUpdateEndpointURL(); diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/viewmodels/ReaderPostListViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/viewmodels/ReaderPostListViewModel.kt index b8c965e5f20c..0a969e23d9d9 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/viewmodels/ReaderPostListViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/viewmodels/ReaderPostListViewModel.kt @@ -295,7 +295,7 @@ class ReaderPostListViewModel @Inject constructor( } fun onEmptyStateButtonTapped(tag: ReaderTag) { - readerViewModel?.selectedTabChange(tag) + readerViewModel?.updateSelectedContent(tag) } // TODO this is related to tracking time spent in reader - diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/viewmodels/ReaderViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/viewmodels/ReaderViewModel.kt index 2ffe72ce283b..e78ad354d41e 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/viewmodels/ReaderViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/viewmodels/ReaderViewModel.kt @@ -1,13 +1,18 @@ package org.wordpress.android.ui.reader.viewmodels +import android.os.Bundle +import android.os.Parcelable import androidx.annotation.DrawableRes import androidx.annotation.StringRes +import androidx.core.os.BundleCompat import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.withContext +import kotlinx.parcelize.Parcelize import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode.MAIN @@ -20,6 +25,8 @@ import org.wordpress.android.models.ReaderTag import org.wordpress.android.models.ReaderTagList import org.wordpress.android.modules.BG_THREAD import org.wordpress.android.modules.UI_THREAD +import org.wordpress.android.ui.Organization +import org.wordpress.android.ui.compose.components.menu.dropdown.MenuElementData import org.wordpress.android.ui.jetpackoverlay.JetpackFeatureRemovalOverlayUtil import org.wordpress.android.ui.jetpackoverlay.JetpackOverlayConnectedFeature.READER import org.wordpress.android.ui.mysite.SelectedSiteRepository @@ -27,23 +34,28 @@ import org.wordpress.android.ui.mysite.cards.quickstart.QuickStartRepository import org.wordpress.android.ui.prefs.AppPrefsWrapper import org.wordpress.android.ui.quickstart.QuickStartEvent import org.wordpress.android.ui.reader.ReaderEvents +import org.wordpress.android.ui.reader.subfilter.SubfilterListItem import org.wordpress.android.ui.reader.tracker.ReaderTab import org.wordpress.android.ui.reader.tracker.ReaderTracker import org.wordpress.android.ui.reader.tracker.ReaderTrackerType.MAIN_READER import org.wordpress.android.ui.reader.usecases.LoadReaderTabsUseCase import org.wordpress.android.ui.reader.utils.DateProvider +import org.wordpress.android.ui.reader.utils.ReaderTopBarMenuHelper import org.wordpress.android.ui.reader.viewmodels.ReaderViewModel.ReaderUiState.ContentUiState -import org.wordpress.android.ui.reader.viewmodels.ReaderViewModel.ReaderUiState.ContentUiState.MenuItemUiState import org.wordpress.android.ui.reader.viewmodels.ReaderViewModel.ReaderUiState.ContentUiState.TabUiState +import org.wordpress.android.ui.reader.views.compose.filter.ReaderFilterSelectedItem +import org.wordpress.android.ui.reader.views.compose.filter.ReaderFilterType import org.wordpress.android.ui.utils.UiString import org.wordpress.android.ui.utils.UiString.UiStringText import org.wordpress.android.util.JetpackBrandingUtils +import org.wordpress.android.util.QuickStartUtils import org.wordpress.android.util.SnackbarSequencer import org.wordpress.android.util.distinct import org.wordpress.android.viewmodel.Event import org.wordpress.android.viewmodel.ScopedViewModel import javax.inject.Inject import javax.inject.Named +import kotlin.coroutines.CoroutineContext const val UPDATE_TAGS_THRESHOLD = 1000 * 60 * 60 // 1 hr const val TRACK_TAB_CHANGED_THROTTLE = 100L @@ -61,7 +73,8 @@ class ReaderViewModel @Inject constructor( private val selectedSiteRepository: SelectedSiteRepository, private val jetpackBrandingUtils: JetpackBrandingUtils, private val snackbarSequencer: SnackbarSequencer, - private val jetpackFeatureRemovalOverlayUtil: JetpackFeatureRemovalOverlayUtil + private val jetpackFeatureRemovalOverlayUtil: JetpackFeatureRemovalOverlayUtil, + private val readerTopBarMenuHelper: ReaderTopBarMenuHelper, // todo: annnmarie removed this private val getFollowedTagsUseCase: GetFollowedTagsUseCase ) : ScopedViewModel(mainDispatcher) { private var initialized: Boolean = false @@ -72,18 +85,15 @@ class ReaderViewModel @Inject constructor( private val _uiState = MutableLiveData() val uiState: LiveData = _uiState.distinct() + private val _topBarUiState = MutableLiveData() + val topBarUiState: LiveData = _topBarUiState.distinct() + private val _updateTags = MutableLiveData>() val updateTags: LiveData> = _updateTags - private val _selectTab = MutableLiveData>() - val selectTab: LiveData> = _selectTab - private val _showSearch = MutableLiveData>() val showSearch: LiveData> = _showSearch - private val _showSettings = MutableLiveData>() - val showSettings: LiveData> = _showSettings - private val _showReaderInterests = MutableLiveData>() val showReaderInterests: LiveData> = _showReaderInterests @@ -99,61 +109,47 @@ class ReaderViewModel @Inject constructor( private val _showJetpackOverlay = MutableLiveData>() val showJetpackOverlay: LiveData> = _showJetpackOverlay + private var readerTagsList = ReaderTagList() + init { EventBus.getDefault().register(this) } - fun start() { + fun start(savedInstanceState: Bundle? = null) { if (tagsRequireUpdate()) _updateTags.value = Event(Unit) if (initialized) return - loadTabs() + loadTabs(savedInstanceState) if (jetpackBrandingUtils.shouldShowJetpackPoweredBottomSheet()) showJetpackPoweredBottomSheet() } + fun onSaveInstanceState(out: Bundle) { + _topBarUiState.value?.let { + out.putString(KEY_TOP_BAR_UI_STATE_SELECTED_ITEM_ID, it.selectedItem.id) + out.putParcelable(KEY_TOP_BAR_UI_STATE_FILTER_UI_STATE, it.filterUiState) + } + } + private fun showJetpackPoweredBottomSheet() { // _showJetpackPoweredBottomSheet.value = Event(true) } - private fun loadTabs() { + private fun loadTabs(savedInstanceState: Bundle? = null) { launch { - val currentContentUiState = _uiState.value as? ContentUiState val tagList = loadReaderTabsUseCase.loadTabs() - if (tagList.isNotEmpty()) { + if (tagList.isNotEmpty() && readerTagsList != tagList) { + updateReaderTagsList(tagList) + updateTopBarUiState(savedInstanceState) _uiState.value = ContentUiState( - tagList.map { TabUiState(label = UiStringText(it.label)) }, - tagList, - shouldUpdateViewPager = currentContentUiState?.readerTagList?.equals(tagList) == false, - searchMenuItemUiState = MenuItemUiState(isVisible = isSearchSupported()), - settingsMenuItemUiState = MenuItemUiState( - isVisible = isSettingsSupported(), - showQuickStartFocusPoint = - currentContentUiState?.settingsMenuItemUiState?.showQuickStartFocusPoint ?: false - ) + tabUiStates = tagList.map { TabUiState(label = UiStringText(it.label)) }, + selectedReaderTag = selectedReaderTag(), ) if (!initialized) { initialized = true - initializeTabSelection(tagList) } } } } - private suspend fun initializeTabSelection(tagList: ReaderTagList) { - withContext(bgDispatcher) { - val selectTab = { readerTag: ReaderTag -> - val index = tagList.indexOf(readerTag) - if (index != -1) { - _selectTab.postValue(Event(TabNavigation(index, smoothAnimation = false))) - } - } - appPrefsWrapper.getReaderTag()?.let { - selectTab.invoke(it) - } ?: tagList.find { it.isDefaultSelectedTab() }?.let { - selectTab.invoke(it) - } - } - } - fun onTagChanged(selectedTag: ReaderTag?) { selectedTag?.let { trackReaderTabShownIfNecessary(it) @@ -170,35 +166,6 @@ class ReaderViewModel @Inject constructor( _showReaderInterests.value = Event(Unit) } - sealed class ReaderUiState( - open val searchMenuItemUiState: MenuItemUiState, - open val settingsMenuItemUiState: MenuItemUiState, - val appBarExpanded: Boolean = false, - val tabLayoutVisible: Boolean = false - ) { - data class ContentUiState( - val tabUiStates: List, - val readerTagList: ReaderTagList, - val shouldUpdateViewPager: Boolean, - override val searchMenuItemUiState: MenuItemUiState, - override val settingsMenuItemUiState: MenuItemUiState - ) : ReaderUiState( - searchMenuItemUiState = searchMenuItemUiState, - settingsMenuItemUiState = settingsMenuItemUiState, - appBarExpanded = true, - tabLayoutVisible = true - ) { - data class TabUiState( - val label: UiString - ) - - data class MenuItemUiState( - val isVisible: Boolean, - val showQuickStartFocusPoint: Boolean = false - ) - } - } - override fun onCleared() { super.onCleared() EventBus.getDefault().unregister(this) @@ -210,17 +177,28 @@ class ReaderViewModel @Inject constructor( return now - lastUpdated > UPDATE_TAGS_THRESHOLD } - fun selectedTabChange(tag: ReaderTag) { - uiState.value?.let { - val currentUiState = it as ContentUiState - val position = currentUiState.readerTagList.indexOfTagName(tag.tagSlug) - _selectTab.postValue(Event(TabNavigation(position, smoothAnimation = true))) + fun updateSelectedContent(selectedTag: ReaderTag) { + getMenuItemFromReaderTag(selectedTag)?.let { newSelectedMenuItem -> + // Update top bar UI state so menu is updated with new selected item + _topBarUiState.value?.let { + _topBarUiState.value = it.copy( + selectedItem = newSelectedMenuItem, + filterUiState = null, + ) + } + // Updated post list content + val currentUiState = _uiState.value as? ContentUiState + currentUiState?.let { + _uiState.value = currentUiState.copy( + selectedReaderTag = selectedReaderTag() + ) + } } } fun bookmarkTabRequested() { - (_uiState.value as? ContentUiState)?.readerTagList?.find { it.isBookmarked }?.let { - selectedTabChange(it) + readerTagsList.find { it.isBookmarked }?.let { + updateSelectedContent(it) } } @@ -233,18 +211,6 @@ class ReaderViewModel @Inject constructor( } } - @Suppress("UseCheckOrError") - fun onSettingsActionClicked() { - if (isSettingsSupported()) { - completeQuickStartFollowSiteTaskIfNeeded() - _showSettings.value = Event(Unit) - } else if (BuildConfig.DEBUG) { - throw IllegalStateException("Settings should be hidden when isSettingsSupported returns false.") - } - } - - private fun ReaderTag.isDefaultSelectedTab(): Boolean = this.isDiscover - @Suppress("unused", "UNUSED_PARAMETER") @Subscribe(threadMode = MAIN) fun onTagsUpdated(event: ReaderEvents.FollowedTagsChanged) { @@ -267,7 +233,6 @@ class ReaderViewModel @Inject constructor( readerTracker.stop(MAIN_READER) wasPaused = true if (!isChangingConfigurations) { - hideQuickStartFocusPointIfNeeded() dismissQuickStartSnackbarIfNeeded() if (quickStartRepository.isPendingTask(getFollowSiteTask())) { quickStartRepository.clearPendingTask() @@ -295,31 +260,12 @@ class ReaderViewModel @Inject constructor( } fun onQuickStartEventReceived(event: QuickStartEvent) { - if (event.task == getFollowSiteTask()) checkAndStartQuickStartFollowSiteTaskNextStep() - } - - private fun checkAndStartQuickStartFollowSiteTaskNextStep() { - val isDiscover = appPrefsWrapper.getReaderTag()?.isDiscover == true - if (isDiscover) { - startQuickStartFollowSiteTaskDiscoverTabStep() - } else { - autoSwitchToDiscoverTab() - } + if (event.task == getFollowSiteTask()) startQuickStartFollowSiteTask() } - private fun autoSwitchToDiscoverTab() { - launch { - if (!initialized) delay(QUICK_START_DISCOVER_TAB_STEP_DELAY) - (_uiState.value as? ContentUiState)?.readerTagList?.find { it.isDiscover }?.let { - selectedTabChange(it) - } - startQuickStartFollowSiteTaskDiscoverTabStep() - } - } - - private fun startQuickStartFollowSiteTaskDiscoverTabStep() { + private fun startQuickStartFollowSiteTask() { val shortMessagePrompt = if (isSettingsSupported()) { - R.string.quick_start_dialog_follow_sites_message_short_discover_and_settings + R.string.quick_start_dialog_follow_sites_message_short_discover_and_subscriptions } else { R.string.quick_start_dialog_follow_sites_message_short_discover } @@ -328,16 +274,15 @@ class ReaderViewModel @Inject constructor( QuickStartReaderPrompt( getFollowSiteTask(), shortMessagePrompt, - R.drawable.ic_cog_white_24dp + QuickStartUtils.ICON_NOT_SET, ) ) - updateContentUiState(showQuickStartFocusPoint = isSettingsSupported()) + completeQuickStartFollowSiteTaskIfNeeded() } - fun completeQuickStartFollowSiteTaskIfNeeded() { + private fun completeQuickStartFollowSiteTaskIfNeeded() { if (quickStartRepository.isPendingTask(getFollowSiteTask())) { selectedSiteRepository.getSelectedSite()?.let { - hideQuickStartFocusPointIfNeeded() quickStartRepository.completeTask(getFollowSiteTask()) } } @@ -348,27 +293,224 @@ class ReaderViewModel @Inject constructor( isQuickStartPromptShown = false } - private fun hideQuickStartFocusPointIfNeeded() { - val currentUiState = _uiState.value as? ContentUiState - if (currentUiState?.settingsMenuItemUiState?.showQuickStartFocusPoint == true) { - updateContentUiState(showQuickStartFocusPoint = false) + private fun getFollowSiteTask() = + quickStartRepository.quickStartType.getTaskFromString(QuickStartStore.QUICK_START_FOLLOW_SITE_LABEL) + + private fun selectedReaderTag(): ReaderTag? = + _topBarUiState.value?.let { + readerTagsList[readerTopBarMenuHelper.getReaderTagIndexFromMenuItem(it.selectedItem)] + } + + private suspend fun updateTopBarUiState(savedInstanceState: Bundle? = null) { + withContext(bgDispatcher) { + val menuItems = readerTopBarMenuHelper.createMenu(readerTagsList) + + // if menu is exactly the same as before, don't update + if (_topBarUiState.value?.menuItems == menuItems) return@withContext + + + // if there's already a selected item, use it, otherwise use the first item, also try to use the saved state + val savedStateSelectedId = savedInstanceState?.getString(KEY_TOP_BAR_UI_STATE_SELECTED_ITEM_ID) + val selectedItem = _topBarUiState.value?.selectedItem + ?: menuItems.filterSingleItems() + .let { singleItems -> + singleItems.firstOrNull { it.id == savedStateSelectedId } ?: singleItems.first() + } + + // if there's a selected item and filter state, also use the filter state, also try to use the saved state + val filterUiState = _topBarUiState.value?.filterUiState + ?.takeIf { _topBarUiState.value?.selectedItem != null } + ?: savedInstanceState + ?.let { + BundleCompat.getParcelable( + it, + KEY_TOP_BAR_UI_STATE_FILTER_UI_STATE, + TopBarUiState.FilterUiState::class.java + ) + } + ?.takeIf { selectedItem.id == savedStateSelectedId } + + _topBarUiState.postValue( + TopBarUiState( + menuItems = menuItems, + selectedItem = selectedItem, + filterUiState = filterUiState, + onDropdownMenuClick = ::onDropdownMenuClick, + isSearchActionVisible = isSearchSupported(), + ) + ) } } - private fun getFollowSiteTask() = - quickStartRepository.quickStartType.getTaskFromString(QuickStartStore.QUICK_START_FOLLOW_SITE_LABEL) + private fun onDropdownMenuClick() { + readerTracker.trackDropdownMenuOpened() + } + + private fun getMenuItemFromReaderTag(readerTag: ReaderTag): MenuElementData.Item.Single? = + _topBarUiState.value?.menuItems + // Selected menu item must be an Item.Single + ?.filterSingleItems() + // Find menu item based onn selected ReaderTag + ?.find { readerTopBarMenuHelper.getReaderTagIndexFromMenuItem(it) == readerTagsList.indexOf(readerTag) } + + private fun List.filterSingleItems(): List { + val singleItems = mutableListOf() + forEach { + if (it is MenuElementData.Item.Single) { + singleItems.add(it) + } else if (it is MenuElementData.Item.SubMenu) { + singleItems.addAll(it.children.filterIsInstance()) + } + } + return singleItems + } + + private fun updateReaderTagsList(readerTags: List) { + readerTagsList.clear() + readerTagsList.addAll(readerTags) + } + + fun onTopBarMenuItemClick(item: MenuElementData.Item.Single) { + val selectedReaderTag = readerTagsList[readerTopBarMenuHelper.getReaderTagIndexFromMenuItem(item)] + + // Avoid reloading a content stream that is already loaded + if (item.id != _topBarUiState.value?.selectedItem?.id) { + selectedReaderTag?.let { updateSelectedContent(it) } + } + if (selectedReaderTag != null) { + readerTracker.trackDropdownMenuItemTapped(selectedReaderTag) + } + } + + fun onSubFilterItemSelected(item: SubfilterListItem) { + when (item) { + is SubfilterListItem.SiteAll -> clearTopBarFilter() + is SubfilterListItem.Site -> updateTopBarFilter(item.blog.name, ReaderFilterType.BLOG) + is SubfilterListItem.Tag -> updateTopBarFilter(item.tag.tagDisplayName, ReaderFilterType.TAG) + else -> Unit // do nothing + } + } + + private fun tryWaitNonNullTopBarUiStateThenRun( + initialDelay: Long = 0L, + retryTime: Long = 50L, + maxRetries: Int = 10, + runContext: CoroutineContext = mainDispatcher, + block: suspend CoroutineScope.(topBarUiState: TopBarUiState) -> Unit + ) { + launch(bgDispatcher) { + if (initialDelay > 0L) delay(initialDelay) + + var remainingTries = maxRetries + while (_topBarUiState.value == null && remainingTries > 0) { + delay(retryTime) + remainingTries-- + } + + // only run the block if the topBarUiState is not null, otherwise do nothing + _topBarUiState.value?.let { topBarUiState -> + withContext(runContext) { + block(topBarUiState) + } + } + } + } + + private fun clearTopBarFilter() { + // small delay to achieve a fluid animation since other UI updates are happening + tryWaitNonNullTopBarUiStateThenRun(initialDelay = FILTER_UPDATE_DELAY) { topBarUiState -> + val filterUiState = topBarUiState.filterUiState?.copy(selectedItem = null) + _topBarUiState.postValue(topBarUiState.copy(filterUiState = filterUiState)) + } + } + + private fun updateTopBarFilter(itemName: String, type: ReaderFilterType) { + // small delay to achieve a fluid animation since other UI updates are happening + tryWaitNonNullTopBarUiStateThenRun(initialDelay = FILTER_UPDATE_DELAY) { topBarUiState -> + val filterUiState = topBarUiState.filterUiState + ?.copy(selectedItem = ReaderFilterSelectedItem(UiStringText(itemName), type)) + _topBarUiState.postValue(topBarUiState.copy(filterUiState = filterUiState)) + } + } + + fun hideTopBarFilterGroup(readerTab: ReaderTag) = tryWaitNonNullTopBarUiStateThenRun { topBarUiState -> + val readerTagIndex = readerTopBarMenuHelper.getReaderTagIndexFromMenuItem(topBarUiState.selectedItem) + val selectedReaderTag = readerTagsList[readerTagIndex] - private fun updateContentUiState( - showQuickStartFocusPoint: Boolean + if (readerTab != selectedReaderTag) return@tryWaitNonNullTopBarUiStateThenRun + + _topBarUiState.postValue(topBarUiState.copy(filterUiState = null)) + } + + fun showTopBarFilterGroup( + readerTab: ReaderTag, + subFilterItems: List + ) = tryWaitNonNullTopBarUiStateThenRun { topBarUiState -> + val readerTagIndex = readerTopBarMenuHelper.getReaderTagIndexFromMenuItem(topBarUiState.selectedItem) + val selectedReaderTag = readerTagsList[readerTagIndex] + + if (readerTab != selectedReaderTag) return@tryWaitNonNullTopBarUiStateThenRun + + val blogsFilterCount = subFilterItems.filterIsInstance().size + val tagsFilterCount = subFilterItems.filterIsInstance().size + + val filterState = topBarUiState.filterUiState + ?.copy( + blogsFilterCount = blogsFilterCount, + tagsFilterCount = tagsFilterCount, + showBlogsFilter = shouldShowBlogsFilter(selectedReaderTag), + showTagsFilter = shouldShowTagsFilter(selectedReaderTag), + ) + ?: TopBarUiState.FilterUiState( + blogsFilterCount = blogsFilterCount, + tagsFilterCount = tagsFilterCount, + showBlogsFilter = shouldShowBlogsFilter(selectedReaderTag), + showTagsFilter = shouldShowTagsFilter(selectedReaderTag), + ) + + _topBarUiState.postValue( + topBarUiState.copy(filterUiState = filterState) + ) + } + + private fun shouldShowBlogsFilter(readerTag: ReaderTag): Boolean { + return readerTag.isFilterable + } + + private fun shouldShowTagsFilter(readerTag: ReaderTag): Boolean { + return readerTag.isFilterable && readerTag.organization == Organization.NO_ORGANIZATION + } + + data class TopBarUiState( + val menuItems: List, + val selectedItem: MenuElementData.Item.Single, + val filterUiState: FilterUiState? = null, + val onDropdownMenuClick: () -> Unit, + val isSearchActionVisible: Boolean = false, ) { - val currentUiState = _uiState.value as? ContentUiState - currentUiState?.let { - _uiState.value = currentUiState.copy( - settingsMenuItemUiState = it.settingsMenuItemUiState.copy( - isVisible = isSettingsSupported(), - showQuickStartFocusPoint = showQuickStartFocusPoint - ), - shouldUpdateViewPager = false + @Parcelize + data class FilterUiState( + val blogsFilterCount: Int, + val tagsFilterCount: Int, + val selectedItem: ReaderFilterSelectedItem? = null, + val showBlogsFilter: Boolean = blogsFilterCount > 0, + val showTagsFilter: Boolean = tagsFilterCount > 0, + ) : Parcelable + } + + sealed class ReaderUiState( + val appBarExpanded: Boolean = false, + val tabLayoutVisible: Boolean = false + ) { + data class ContentUiState( + val tabUiStates: List, + val selectedReaderTag: ReaderTag?, + ) : ReaderUiState( + appBarExpanded = true, + tabLayoutVisible = true + ) { + data class TabUiState( + val label: UiString ) } } @@ -381,8 +523,11 @@ class ReaderViewModel @Inject constructor( ) companion object { - private const val QUICK_START_DISCOVER_TAB_STEP_DELAY = 2000L private const val QUICK_START_PROMPT_DURATION = 5000 + private const val FILTER_UPDATE_DELAY = 50L + + private const val KEY_TOP_BAR_UI_STATE_SELECTED_ITEM_ID = "topBarUiState_selectedItem_id" + private const val KEY_TOP_BAR_UI_STATE_FILTER_UI_STATE = "topBarUiState_filterUiState" } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/viewmodels/SubfilterPageViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/viewmodels/SubfilterPageViewModel.kt index 3b4f9b8c810e..6f06c5a323e8 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/viewmodels/SubfilterPageViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/viewmodels/SubfilterPageViewModel.kt @@ -6,13 +6,13 @@ import androidx.lifecycle.ViewModel import org.wordpress.android.R import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.ui.reader.ReaderSubsActivity -import org.wordpress.android.ui.reader.subfilter.ActionType.OpenLoginPage -import org.wordpress.android.ui.reader.subfilter.ActionType.OpenSubsAtPage +import org.wordpress.android.ui.reader.subfilter.ActionType import org.wordpress.android.ui.reader.subfilter.SubfilterBottomSheetEmptyUiState import org.wordpress.android.ui.reader.subfilter.SubfilterBottomSheetEmptyUiState.HiddenEmptyUiState import org.wordpress.android.ui.reader.subfilter.SubfilterBottomSheetEmptyUiState.VisibleEmptyUiState import org.wordpress.android.ui.reader.subfilter.SubfilterCategory import org.wordpress.android.ui.reader.subfilter.SubfilterCategory.SITES +import org.wordpress.android.ui.reader.subfilter.SubfilterCategory.TAGS import org.wordpress.android.ui.utils.UiString.UiStringRes import javax.inject.Inject @@ -39,46 +39,55 @@ class SubfilterPageViewModel @Inject constructor( fun onSubFiltersChanged(isEmpty: Boolean) { _emptyState.value = if (isEmpty) { + val primaryButton = if (accountStore.hasAccessToken()) { + VisibleEmptyUiState.Button( + text = UiStringRes(R.string.reader_filter_empty_tags_action_suggested), + action = ActionType.OpenSuggestedTagsPage + ).takeIf { category == TAGS } + } else { + VisibleEmptyUiState.Button( + text = UiStringRes(R.string.reader_filter_self_hosted_empty_sites_tags_action), + action = ActionType.OpenLoginPage + ) + } + + val secondaryButton = if (category == SITES) { + VisibleEmptyUiState.Button( + text = UiStringRes(R.string.reader_filter_empty_blogs_action_search), + action = ActionType.OpenSearchPage + ) + } else { + VisibleEmptyUiState.Button( + text = UiStringRes(R.string.reader_filter_empty_tags_action_subscribe), + action = ActionType.OpenSubsAtPage(ReaderSubsActivity.TAB_IDX_FOLLOWED_TAGS) + ) + } + VisibleEmptyUiState( title = UiStringRes( if (category == SITES) { - if (accountStore.hasAccessToken()) { - R.string.reader_filter_empty_sites_list - } else { - R.string.reader_filter_self_hosted_empty_sites_list - } + R.string.reader_filter_empty_blogs_list_title } else { - if (accountStore.hasAccessToken()) { - R.string.reader_filter_empty_tags_list - } else { - R.string.reader_filter_self_hosted_empty_tagss_list - } + R.string.reader_filter_empty_tags_list_title } - ), - buttonText = UiStringRes( + ).takeIf { accountStore.hasAccessToken() }, + text = UiStringRes( if (category == SITES) { if (accountStore.hasAccessToken()) { - R.string.reader_filter_empty_sites_action + R.string.reader_filter_empty_blogs_list_text } else { - R.string.reader_filter_self_hosted_empty_sites_tags_action + R.string.reader_filter_self_hosted_empty_blogs_list } } else { if (accountStore.hasAccessToken()) { - R.string.reader_filter_empty_tags_action + R.string.reader_filter_empty_tags_list_text } else { - R.string.reader_filter_self_hosted_empty_sites_tags_action + R.string.reader_filter_self_hosted_empty_tags_list } } ), - action = if (accountStore.hasAccessToken()) { - if (category == SITES) { - OpenSubsAtPage(ReaderSubsActivity.TAB_IDX_FOLLOWED_BLOGS) - } else { - OpenSubsAtPage(ReaderSubsActivity.TAB_IDX_FOLLOWED_TAGS) - } - } else { - OpenLoginPage - } + primaryButton = primaryButton, + secondaryButton = secondaryButton.takeIf { accountStore.hasAccessToken() }, ) } else { HiddenEmptyUiState diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/views/ReaderFollowButton.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/views/ReaderFollowButton.kt index 0f7f57cf269f..30b3338c7545 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/views/ReaderFollowButton.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/views/ReaderFollowButton.kt @@ -45,7 +45,7 @@ class ReaderFollowButton @JvmOverloads constructor( private fun updateFollowText() { if (showCaption) { - setText(if (isFollowed) R.string.reader_btn_unfollow else R.string.reader_btn_follow) + setText(if (isFollowed) R.string.reader_btn_subscribed else R.string.reader_btn_subscribe) } isSelected = isFollowed } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/views/ReaderSiteHeaderView.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/views/ReaderSiteHeaderView.java index 8774c71f249e..7fb609c0941b 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/views/ReaderSiteHeaderView.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/views/ReaderSiteHeaderView.java @@ -105,7 +105,7 @@ public void loadBlogInfo( final ReaderBlog localBlogInfo; if (blogId == 0 && feedId == 0) { - ToastUtils.showToast(getContext(), R.string.reader_toast_err_get_blog_info); + ToastUtils.showToast(getContext(), R.string.reader_toast_err_show_blog); return; } @@ -218,9 +218,9 @@ private void loadFollowCount(ReaderBlog blogInfo, TextView txtFollowCount) { final int followersStringRes; if (blogInfo.numSubscribers == 1) { - followersStringRes = R.string.reader_label_followers_count_single; + followersStringRes = R.string.reader_label_subscribers_count_single; } else { - followersStringRes = R.string.reader_label_followers_count; + followersStringRes = R.string.reader_label_subscribers_count; } final String formattedNumberSubscribers; @@ -237,7 +237,7 @@ private void loadFollowCount(ReaderBlog blogInfo, TextView txtFollowCount) { } else { txtFollowCount.setText(String.format( LocaleManager.getSafeLocale(getContext()), - getContext().getString(R.string.reader_label_follow_count), + getContext().getString(R.string.reader_label_subscribe_count), blogInfo.numSubscribers)); } } @@ -283,8 +283,8 @@ private void toggleFollowStatus(final View followButton, final String source) { } mFollowButton.setEnabled(true); if (!succeeded) { - int errResId = isAskingToFollow ? R.string.reader_toast_err_follow_blog - : R.string.reader_toast_err_unfollow_blog; + int errResId = isAskingToFollow ? R.string.reader_toast_err_unable_to_follow_blog + : R.string.reader_toast_err_unable_to_unfollow_blog; ToastUtils.showToast(getContext(), errResId); mFollowButton.setIsFollowed(!isAskingToFollow); } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/views/ReaderSiteSearchResultView.java b/WordPress/src/main/java/org/wordpress/android/ui/reader/views/ReaderSiteSearchResultView.java index 89d259af6e0a..9358ba1a99f1 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/reader/views/ReaderSiteSearchResultView.java +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/views/ReaderSiteSearchResultView.java @@ -99,8 +99,8 @@ public void onActionResult(boolean succeeded) { } mFollowButton.setEnabled(true); if (!succeeded) { - int errResId = isAskingToFollow ? R.string.reader_toast_err_follow_blog - : R.string.reader_toast_err_unfollow_blog; + int errResId = isAskingToFollow ? R.string.reader_toast_err_unable_to_follow_blog + : R.string.reader_toast_err_unable_to_unfollow_blog; ToastUtils.showToast(getContext(), errResId); mFollowButton.setIsFollowed(!isAskingToFollow); mSite.setFollowing(!isAskingToFollow); diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/views/compose/ReaderTopAppBar.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/views/compose/ReaderTopAppBar.kt new file mode 100644 index 000000000000..6578d1ba6b5d --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/views/compose/ReaderTopAppBar.kt @@ -0,0 +1,238 @@ +package org.wordpress.android.ui.reader.views.compose + +import android.content.res.Configuration +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material.MaterialTheme +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import org.wordpress.android.R +import org.wordpress.android.ui.compose.components.menu.dropdown.JetpackDropdownMenu +import org.wordpress.android.ui.compose.components.menu.dropdown.MenuElementData +import org.wordpress.android.ui.compose.theme.AppTheme +import org.wordpress.android.ui.compose.unit.Margin +import org.wordpress.android.ui.compose.utils.horizontalFadingEdges +import org.wordpress.android.ui.reader.viewmodels.ReaderViewModel +import org.wordpress.android.ui.reader.views.compose.filter.ReaderFilterChipGroup +import org.wordpress.android.ui.reader.views.compose.filter.ReaderFilterType +import org.wordpress.android.ui.utils.UiString + +private const val ANIM_DURATION = 300 +private val chipHeight = 36.dp + +@Composable +fun ReaderTopAppBar( + topBarUiState: ReaderViewModel.TopBarUiState, + onMenuItemClick: (MenuElementData.Item.Single) -> Unit, + onFilterClick: (ReaderFilterType) -> Unit, + onClearFilterClick: () -> Unit, + isSearchVisible: Boolean, + onSearchClick: () -> Unit = {}, +) { + var selectedItem by remember { mutableStateOf(topBarUiState.selectedItem) } + var isFilterShown by remember { mutableStateOf(topBarUiState.filterUiState != null) } + var latestFilterState by remember { mutableStateOf(topBarUiState.filterUiState) } + + // Coordinate filter enter and exit animations with the dropdown menu (delays are required for a nice experience) + val shouldShowFilter = topBarUiState.filterUiState != null + LaunchedEffect(shouldShowFilter, topBarUiState.selectedItem) { + if (isFilterShown != shouldShowFilter) { + isFilterShown = shouldShowFilter + if (!shouldShowFilter) delay(ANIM_DURATION.toLong()) + } + selectedItem = topBarUiState.selectedItem + } + + // Update filter state when it changes to non-null value. We need to keep it non-null so that the exit animation + // works properly. + if (topBarUiState.filterUiState != null) { + latestFilterState = topBarUiState.filterUiState + } + + Row( + modifier = Modifier + .fillMaxWidth() + .height(52.dp) + ) { + Box( + modifier = Modifier + .fillMaxHeight() + .weight(1f) + ) { + val scrollState = rememberScrollState() + Row( + modifier = Modifier + .fillMaxSize() + .horizontalScroll(scrollState) + .horizontalFadingEdges(scrollState, startEdgeSize = 0.dp) + .padding(start = Margin.ExtraLarge.value), + verticalAlignment = Alignment.CenterVertically, + ) { + JetpackDropdownMenu( + selectedItem = selectedItem, + menuItems = topBarUiState.menuItems, + onSingleItemClick = onMenuItemClick, + menuButtonHeight = chipHeight, + contentSizeAnimation = tween(ANIM_DURATION), + onDropdownMenuClick = topBarUiState.onDropdownMenuClick, + ) + + AnimatedVisibility( + visible = isFilterShown, + enter = fadeIn(tween(delayMillis = ANIM_DURATION)) + + slideInHorizontally(tween(delayMillis = ANIM_DURATION)) { -it / 2 }, + exit = fadeOut(tween(ANIM_DURATION)) + + slideOutHorizontally(tween(ANIM_DURATION)) { -it / 2 }, + ) { + latestFilterState?.let { filterUiState -> + Filter( + filterUiState = filterUiState, + onFilterClick = onFilterClick, + onClearFilterClick = onClearFilterClick, + modifier = Modifier + // use padding instead of Spacer for a nicer animation + .padding(start = Margin.Medium.value), + ) + } + } + } + } + Spacer(Modifier.width(Margin.ExtraSmall.value)) + if (isSearchVisible) { + IconButton( + modifier = Modifier.align(Alignment.CenterVertically), + onClick = { onSearchClick() } + ) { + Icon( + painter = painterResource(R.drawable.ic_magnifying_glass_16dp), + contentDescription = stringResource( + R.string.reader_search_content_description + ), + tint = MaterialTheme.colors.onSurface, + ) + } + } + } +} + +@Composable +private fun Filter( + filterUiState: ReaderViewModel.TopBarUiState.FilterUiState, + onFilterClick: (ReaderFilterType) -> Unit, + onClearFilterClick: () -> Unit, + modifier: Modifier = Modifier, +) { + ReaderFilterChipGroup( + modifier = modifier, + selectedItem = filterUiState.selectedItem, + blogsFilterCount = filterUiState.blogsFilterCount, + tagsFilterCount = filterUiState.tagsFilterCount, + showBlogsFilter = filterUiState.showBlogsFilter, + showTagsFilter = filterUiState.showTagsFilter, + onFilterClick = onFilterClick, + onSelectedItemClick = { filterUiState.selectedItem?.type?.let(onFilterClick) }, + onSelectedItemDismissClick = onClearFilterClick, + chipHeight = chipHeight, + ) +} + +@Preview +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES) +@Composable +fun ReaderTopAppBarPreview() { + val menuItems = mutableListOf( + MenuElementData.Item.Single( + id = "discover", + text = UiString.UiStringRes(R.string.reader_dropdown_menu_discover), + leadingIcon = R.drawable.ic_reader_discover_24dp, + ), + MenuElementData.Item.Single( + id = "subscriptions", + text = UiString.UiStringRes(R.string.reader_dropdown_menu_subscriptions), + leadingIcon = R.drawable.ic_reader_subscriptions_24dp, + ), + MenuElementData.Item.Single( + id = "saved", + text = UiString.UiStringRes(R.string.reader_dropdown_menu_saved), + leadingIcon = R.drawable.ic_reader_saved_24dp, + ), + MenuElementData.Item.Single( + id = "liked", + text = UiString.UiStringRes(R.string.reader_dropdown_menu_liked), + leadingIcon = R.drawable.ic_reader_liked_24dp, + ), + MenuElementData.Item.SubMenu( + id = "subMenu1", + text = UiString.UiStringText("Funny Blogs"), + children = listOf( + MenuElementData.Item.Single( + id = "funnyBlog1", + text = UiString.UiStringText("Funny Blog 1"), + ), + MenuElementData.Item.Single( + id = "funnyBlog2", + text = UiString.UiStringText("Funny Blog 2"), + ), + ), + ) + ) + + var topBarUiState by remember { + mutableStateOf( + ReaderViewModel.TopBarUiState( + menuItems = menuItems, + selectedItem = menuItems.first() as MenuElementData.Item.Single, + onDropdownMenuClick = {}, + ) + ) + } + + AppTheme { + Box( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight() + ) { + ReaderTopAppBar( + topBarUiState = topBarUiState, + onMenuItemClick = { + topBarUiState = topBarUiState.copy( + selectedItem = it + ) + }, + onFilterClick = {}, + onClearFilterClick = {}, + isSearchVisible = true, + onSearchClick = {}, + ) + } + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/reader/views/compose/filter/ReaderFilterChipGroup.kt b/WordPress/src/main/java/org/wordpress/android/ui/reader/views/compose/filter/ReaderFilterChipGroup.kt new file mode 100644 index 000000000000..8e810354e404 --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/ui/reader/views/compose/filter/ReaderFilterChipGroup.kt @@ -0,0 +1,247 @@ +package org.wordpress.android.ui.reader.views.compose.filter + +import android.content.res.Configuration.UI_MODE_NIGHT_YES +import android.os.Parcelable +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.RoundedCornerShape +import androidx.compose.material.Icon +import androidx.compose.material.LocalContentAlpha +import androidx.compose.material.LocalContentColor +import androidx.compose.material.MaterialTheme +import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +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.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlinx.parcelize.Parcelize +import org.wordpress.android.R +import org.wordpress.android.ui.compose.theme.AppThemeWithoutBackground +import org.wordpress.android.ui.compose.unit.Margin +import org.wordpress.android.ui.compose.utils.uiStringText +import org.wordpress.android.ui.utils.UiString +import androidx.compose.material3.MaterialTheme as Material3Theme + +private val roundedShape = RoundedCornerShape(100) + +@Composable +fun ReaderFilterChipGroup( + blogsFilterCount: Int, + tagsFilterCount: Int, + onFilterClick: (ReaderFilterType) -> Unit, + onSelectedItemClick: () -> Unit, + onSelectedItemDismissClick: () -> Unit, + modifier: Modifier = Modifier, + selectedItem: ReaderFilterSelectedItem? = null, + showBlogsFilter: Boolean = blogsFilterCount > 0, + showTagsFilter: Boolean = tagsFilterCount > 0, + chipHeight: Dp = 36.dp, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + ) { + val isBlogSelected = selectedItem?.type == ReaderFilterType.BLOG + val isTagSelected = selectedItem?.type == ReaderFilterType.TAG + val isBlogChipVisible = showBlogsFilter && (selectedItem == null || isBlogSelected) + val isTagChipVisible = showTagsFilter && (selectedItem == null || isTagSelected) + + val blogChipText: UiString = remember(selectedItem, blogsFilterCount) { + if (isBlogSelected) { + selectedItem?.text ?: UiString.UiStringText("") + } else { + UiString.UiStringPluralRes( + zeroRes = R.string.reader_filter_chip_blog_zero, + oneRes = R.string.reader_filter_chip_blog_one, + otherRes = R.string.reader_filter_chip_blog_other, + count = blogsFilterCount, + ) + } + } + + val tagChipText: UiString = remember(selectedItem, tagsFilterCount) { + if (isTagSelected) { + selectedItem?.text ?: UiString.UiStringText("") + } else { + UiString.UiStringPluralRes( + zeroRes = R.string.reader_filter_chip_tag_zero, + oneRes = R.string.reader_filter_chip_tag_one, + otherRes = R.string.reader_filter_chip_tag_other, + count = tagsFilterCount, + ) + } + } + + // blogs filter chip + AnimatedVisibility( + modifier = Modifier.clip(roundedShape), + visible = isBlogChipVisible, + ) { + ReaderFilterChip( + text = blogChipText, + onClick = if (isBlogSelected) onSelectedItemClick else ({ onFilterClick(ReaderFilterType.BLOG) }), + onDismissClick = if (isBlogSelected) onSelectedItemDismissClick else null, + isSelectedItem = isBlogSelected, + height = chipHeight, + ) + } + + AnimatedVisibility(visible = isBlogChipVisible && isTagChipVisible) { + Spacer(Modifier.width(Margin.Medium.value)) + } + + // tags filter chip + AnimatedVisibility( + modifier = Modifier.clip(roundedShape), + visible = isTagChipVisible, + ) { + ReaderFilterChip( + text = tagChipText, + onClick = if (isTagSelected) onSelectedItemClick else ({ onFilterClick(ReaderFilterType.TAG) }), + onDismissClick = if (isTagSelected) onSelectedItemDismissClick else null, + isSelectedItem = isTagSelected, + height = chipHeight, + ) + } + } +} + +@Composable +fun ReaderFilterChip( + text: UiString, + onClick: () -> Unit, + height: Dp, + modifier: Modifier = Modifier, + isSelectedItem: Boolean = false, + onDismissClick: (() -> Unit)? = null, +) { + val backgroundColor by animateColorAsState( + label = "ReaderFilterChip backgroundColor", + targetValue = if (isSelectedItem) { + MaterialTheme.colors.onSurface + } else { + MaterialTheme.colors.onSurface.copy(alpha = 0.1f) + } + ) + + val contentColor by animateColorAsState( + label = "ReaderFilterChip contentColor", + targetValue = if (isSelectedItem) { + MaterialTheme.colors.surface + } else { + MaterialTheme.colors.onSurface + } + ) + + val endPadding by animateDpAsState( + label = "ReaderFilterChip endPadding", + targetValue = if (onDismissClick != null) Margin.Large.value else Margin.ExtraLarge.value, + ) + + CompositionLocalProvider( + LocalContentColor provides contentColor, + LocalContentAlpha provides 1f, + ) { + Row( + modifier = modifier + .background( + color = backgroundColor, + shape = roundedShape, + ) + .clip(roundedShape) + .height(height) + .clickable(onClick = onClick) + .padding( + start = Margin.ExtraLarge.value, + end = endPadding, + ) + .animateContentSize(), + horizontalArrangement = Arrangement.spacedBy(Margin.Small.value), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + uiStringText(text), + style = Material3Theme.typography.titleMedium, + modifier = Modifier + .align(Alignment.CenterVertically), + ) + + if (onDismissClick != null) { + Icon( + Icons.Default.Close, + contentDescription = stringResource(R.string.dismiss), + modifier = Modifier + .size(20.dp) + .padding(Margin.ExtraSmall.value) + .clickable( + onClick = onDismissClick, + role = Role.Button, + ), + ) + } + } + } +} + +enum class ReaderFilterType { + BLOG, + TAG, +} + +@Parcelize +data class ReaderFilterSelectedItem( + val text: UiString, + val type: ReaderFilterType, +) : Parcelable + +@Preview(name = "Light Mode", showBackground = true) +@Preview(name = "Dark Mode", showBackground = true, uiMode = UI_MODE_NIGHT_YES) +@Composable +fun ReaderFilterChipGroupPreview() { + var selectedItem: ReaderFilterSelectedItem? by rememberSaveable { mutableStateOf(null) } + + AppThemeWithoutBackground { + ReaderFilterChipGroup( + modifier = Modifier.padding(Margin.Medium.value), + selectedItem = selectedItem, + blogsFilterCount = 23, + tagsFilterCount = 41, + onFilterClick = { type -> + selectedItem = ReaderFilterSelectedItem( + text = UiString.UiStringText("Amazing ${type.name.lowercase()}"), + type = type, + ) + }, + onSelectedItemClick = { + selectedItem = null + }, + onSelectedItemDismissClick = { + selectedItem = null + }, + ) + } +} diff --git a/WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorParentActivity.kt b/WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorParentActivity.kt index c1773d48c83e..fa171c6ade04 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorParentActivity.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorParentActivity.kt @@ -19,9 +19,13 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.material.Button +import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.MaterialTheme import androidx.compose.material.Scaffold import androidx.compose.material.Text +import androidx.compose.material.pullrefresh.PullRefreshIndicator +import androidx.compose.material.pullrefresh.pullRefresh +import androidx.compose.material.pullrefresh.rememberPullRefreshState import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Surface import androidx.compose.material3.Tab @@ -221,24 +225,24 @@ class SiteMonitorParentActivity : AppCompatActivity(), SiteMonitorWebViewClient. val uiState by remember(key1 = tabType) { siteMonitorParentViewModel.getUiState(tabType) } - LazyColumn { - item { - when (uiState) { - is SiteMonitorUiState.Preparing -> LoadingState(modifier) - is SiteMonitorUiState.Prepared, is SiteMonitorUiState.Loaded -> - SiteMonitorWebView(uiState, tabType, modifier) - is SiteMonitorUiState.Error -> SiteMonitorError(uiState as SiteMonitorUiState.Error, modifier) - } - } + when (uiState) { + is SiteMonitorUiState.Preparing -> LoadingState(modifier) + is SiteMonitorUiState.Prepared, is SiteMonitorUiState.Loaded -> + SiteMonitorWebViewContent(uiState, tabType, modifier) + + is SiteMonitorUiState.Error -> SiteMonitorError(uiState as SiteMonitorUiState.Error, modifier) } } + @Composable fun LoadingState(modifier: Modifier = Modifier) { Box( contentAlignment = Alignment.Center, modifier = modifier.fillMaxSize() ) { - CircularProgressIndicator() + CircularProgressIndicator( + color = MaterialTheme.colors.onSurface + ) } } @@ -276,37 +280,64 @@ class SiteMonitorParentActivity : AppCompatActivity(), SiteMonitorWebViewClient. @SuppressLint("SetJavaScriptEnabled") @Composable - private fun SiteMonitorWebView( + private fun SiteMonitorWebViewContent( uiState: SiteMonitorUiState, tabType: SiteMonitorType, modifier: Modifier = Modifier ) { // retrieve the webview from the actvity - var webView = when (tabType) { + val webView = when (tabType) { SiteMonitorType.METRICS -> metricsWebView SiteMonitorType.PHP_LOGS -> phpLogsWebView SiteMonitorType.WEB_SERVER_LOGS -> webServerLogsWebView } - if (uiState is SiteMonitorUiState.Prepared) { - webView.postUrl(WPWebViewActivity.WPCOM_LOGIN_URL, uiState.model.addressToLoad.toByteArray()) + when(uiState) { + is SiteMonitorUiState.Prepared -> { + webView.postUrl(WPWebViewActivity.WPCOM_LOGIN_URL, uiState.model.addressToLoad.toByteArray()) + LoadingState() + } + is SiteMonitorUiState.Loaded -> { + SiteMonitorWebView(webView, tabType, modifier) + } + else -> {} } + } + + @OptIn(ExperimentalMaterialApi::class) + @Composable + private fun SiteMonitorWebView(tabWebView: WebView, tabType: SiteMonitorType, modifier: Modifier = Modifier) { + // the webview is retrieved from the activity, so we need to use a mutable variable + // to assign to android view + var webView = tabWebView + + val refreshState = siteMonitorParentViewModel.getRefreshState(tabType) + + val pullRefreshState = rememberPullRefreshState( + refreshing = refreshState.value, + onRefresh = { siteMonitorParentViewModel.refreshData(tabType) } + ) Box( - modifier = modifier.fillMaxSize(), - contentAlignment = Alignment.Center + modifier = modifier + .fillMaxSize() + .pullRefresh(pullRefreshState) ) { - if (uiState is SiteMonitorUiState.Prepared) { - LoadingState() - } else { - webView.let { theWebView -> + LazyColumn(modifier = Modifier.fillMaxHeight()) { + item { AndroidView( - factory = { theWebView }, + factory = { webView }, update = { webView = it }, modifier = Modifier.fillMaxWidth() ) } } + PullRefreshIndicator( + refreshing = refreshState.value, + state = pullRefreshState, + modifier = Modifier.align(Alignment.TopCenter), + contentColor = MaterialTheme.colors.primaryVariant, + ) } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorParentViewModel.kt b/WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorParentViewModel.kt index 2a18f07e1fb2..104800214320 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorParentViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorParentViewModel.kt @@ -92,4 +92,36 @@ class SiteMonitorParentViewModel @Inject constructor( phpLogViewModel.onCleared() webServerViewModel.onCleared() } + + fun getRefreshState(siteMonitorType: SiteMonitorType): State { + return when (siteMonitorType) { + SiteMonitorType.METRICS -> { + metricsViewModel.isRefreshing + } + + SiteMonitorType.PHP_LOGS -> { + phpLogViewModel.isRefreshing + } + + SiteMonitorType.WEB_SERVER_LOGS -> { + webServerViewModel.isRefreshing + } + } + } + + fun refreshData(siteMonitorType: SiteMonitorType) { + when(siteMonitorType) { + SiteMonitorType.METRICS -> { + metricsViewModel.refreshData() + } + + SiteMonitorType.PHP_LOGS -> { + phpLogViewModel.refreshData() + } + + SiteMonitorType.WEB_SERVER_LOGS -> { + webServerViewModel.refreshData() + } + } + } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorTabViewModelSlice.kt b/WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorTabViewModelSlice.kt index 45ec9a56d76f..91dea1294b59 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorTabViewModelSlice.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/sitemonitor/SiteMonitorTabViewModelSlice.kt @@ -3,22 +3,29 @@ package org.wordpress.android.ui.sitemonitor import android.text.TextUtils import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import org.wordpress.android.fluxc.model.SiteModel import org.wordpress.android.fluxc.store.AccountStore import org.wordpress.android.fluxc.store.SiteStore +import org.wordpress.android.modules.BG_THREAD import org.wordpress.android.util.NetworkUtilsWrapper import javax.inject.Inject +import javax.inject.Named + +const val REFRESH_DELAY = 500L class SiteMonitorTabViewModelSlice @Inject constructor( + @param:Named(BG_THREAD) private val bgDispatcher: CoroutineDispatcher, private val networkUtilsWrapper: NetworkUtilsWrapper, private val accountStore: AccountStore, private val mapper: SiteMonitorMapper, private val siteMonitorUtils: SiteMonitorUtils, private val siteStore: SiteStore, -){ +) { private lateinit var scope: CoroutineScope private lateinit var site: SiteModel @@ -28,6 +35,9 @@ class SiteMonitorTabViewModelSlice @Inject constructor( private val _uiState = mutableStateOf(SiteMonitorUiState.Preparing) val uiState: State = _uiState + private val _isRefreshing = mutableStateOf(false) + val isRefreshing: State = _isRefreshing + fun initialize(scope: CoroutineScope) { this.scope = scope } @@ -50,7 +60,19 @@ class SiteMonitorTabViewModelSlice @Inject constructor( assembleAndShowSiteMonitor() } - private fun checkForInternetConnectivityAndPostErrorIfNeeded() : Boolean { + fun refreshData() { + scope.launch { + _isRefreshing.value = true + // this delay is to prevent the refresh from being too fast + // so that the user can see the refresh animation + // also this would fix the unit tests + delay(REFRESH_DELAY) + loadView() + _isRefreshing.value = false + } + } + + private fun checkForInternetConnectivityAndPostErrorIfNeeded(): Boolean { if (networkUtilsWrapper.isNetworkAvailable()) return true postUiState(mapper.toNoNetworkError(::loadView)) return false @@ -68,8 +90,10 @@ class SiteMonitorTabViewModelSlice @Inject constructor( val sanitizedUrl = siteMonitorUtils.sanitizeSiteUrl(site.url) val url = urlTemplate.replace("{blog}", sanitizedUrl) - val addressToLoad = prepareAddressToLoad(url) - postUiState(mapper.toPrepared(url, addressToLoad, siteMonitorType)) + scope.launch(bgDispatcher) { + val addressToLoad = prepareAddressToLoad(url) + postUiState(mapper.toPrepared(url, addressToLoad, siteMonitorType)) + } } private fun prepareAddressToLoad(url: String): String { @@ -95,7 +119,7 @@ class SiteMonitorTabViewModelSlice @Inject constructor( addressToLoad, username, "", - accessToken?:"" + accessToken ?: "" ) } @@ -106,7 +130,7 @@ class SiteMonitorTabViewModelSlice @Inject constructor( } fun onUrlLoaded() { - if (uiState.value is SiteMonitorUiState.Prepared){ + if (uiState.value is SiteMonitorUiState.Prepared) { postUiState(SiteMonitorUiState.Loaded) } } diff --git a/WordPress/src/main/java/org/wordpress/android/ui/utils/UiString.kt b/WordPress/src/main/java/org/wordpress/android/ui/utils/UiString.kt index bdddae84344a..b3260b617a31 100644 --- a/WordPress/src/main/java/org/wordpress/android/ui/utils/UiString.kt +++ b/WordPress/src/main/java/org/wordpress/android/ui/utils/UiString.kt @@ -1,20 +1,26 @@ package org.wordpress.android.ui.utils +import android.os.Parcelable import androidx.annotation.StringRes +import kotlinx.parcelize.Parcelize /** * [UiString] is a utility sealed class that represents a string to be used in the UI. It allows a string to be * represented as both string resource and text. */ -sealed class UiString { +sealed class UiString : Parcelable { + @Parcelize data class UiStringText(val text: CharSequence) : UiString() + @Parcelize data class UiStringRes(@StringRes val stringRes: Int) : UiString() + @Parcelize data class UiStringResWithParams(@StringRes val stringRes: Int, val params: List) : UiString() { constructor(@StringRes stringRes: Int, vararg varargParams: UiString) : this(stringRes, varargParams.toList()) } // Current localization process does not support resource strings, // so we need to use multiple string resources. Switch to @PluralsRes when it is supported by localization process. + @Parcelize data class UiStringPluralRes( @StringRes val zeroRes: Int, @StringRes val oneRes: Int, diff --git a/WordPress/src/main/java/org/wordpress/android/util/extensions/CollectionExtensions.kt b/WordPress/src/main/java/org/wordpress/android/util/extensions/CollectionExtensions.kt index 13d6472c10e0..7815eab0071b 100644 --- a/WordPress/src/main/java/org/wordpress/android/util/extensions/CollectionExtensions.kt +++ b/WordPress/src/main/java/org/wordpress/android/util/extensions/CollectionExtensions.kt @@ -3,3 +3,8 @@ package org.wordpress.android.util.extensions fun Collection.doesNotContain(element: T): Boolean = !contains(element) fun Collection.hasOneElement(): Boolean = size == 1 + +fun Collection.indexOrNull(predicate: (T) -> Boolean): Int? = + indexOfFirst(predicate).let { + if (it == -1) null else it + } diff --git a/WordPress/src/main/java/org/wordpress/android/util/extensions/InAppReviewExtensions.kt b/WordPress/src/main/java/org/wordpress/android/util/extensions/InAppReviewExtensions.kt new file mode 100644 index 000000000000..5585efcdaeda --- /dev/null +++ b/WordPress/src/main/java/org/wordpress/android/util/extensions/InAppReviewExtensions.kt @@ -0,0 +1,15 @@ +package org.wordpress.android.util.extensions + +import com.google.android.gms.tasks.Task +import com.google.android.play.core.review.ReviewException +import com.google.android.play.core.review.ReviewInfo +import com.google.android.play.core.review.model.ReviewErrorCode +import org.wordpress.android.util.AppLog + +fun Task.logException() { + val errorMessage = "Error fetching ReviewInfo object from Review API to start in-app review process" + (exception as? ReviewException)?.let { + @ReviewErrorCode val reviewErrorCode = it.errorCode + AppLog.e(AppLog.T.UTILS, errorMessage, reviewErrorCode) + } ?: AppLog.e(AppLog.T.UTILS, "$errorMessage: ${exception?.message}") +} diff --git a/WordPress/src/main/java/org/wordpress/android/viewmodel/main/WPMainActivityViewModel.kt b/WordPress/src/main/java/org/wordpress/android/viewmodel/main/WPMainActivityViewModel.kt index 9bb2b84343d1..7dc645b371aa 100644 --- a/WordPress/src/main/java/org/wordpress/android/viewmodel/main/WPMainActivityViewModel.kt +++ b/WordPress/src/main/java/org/wordpress/android/viewmodel/main/WPMainActivityViewModel.kt @@ -6,7 +6,6 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.distinctUntilChanged import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.firstOrNull import org.wordpress.android.R import org.wordpress.android.analytics.AnalyticsTracker.Stat @@ -56,7 +55,6 @@ import java.util.Locale import javax.inject.Inject import javax.inject.Named -private const val SWITCH_TO_MY_SITE_DELAY = 500L private const val ONE_SITE = 1 class WPMainActivityViewModel @Inject constructor( @@ -113,11 +111,8 @@ class WPMainActivityViewModel @Inject constructor( private val _isBottomSheetShowing = MutableLiveData>() val isBottomSheetShowing: LiveData> = _isBottomSheetShowing - private val _startLoginFlow = MutableLiveData>() - val startLoginFlow: LiveData> = _startLoginFlow - - private val _switchToMySite = MutableLiveData>() - val switchToMySite: LiveData> = _switchToMySite + private val _switchToMeTab = MutableLiveData>() + val switchToMeTab: LiveData> = _switchToMeTab private val _onFeatureAnnouncementRequested = SingleLiveEvent() val onFeatureAnnouncementRequested: LiveData = _onFeatureAnnouncementRequested @@ -305,11 +300,8 @@ class WPMainActivityViewModel @Inject constructor( setMainFabUiState(showFab, site) } - fun onOpenLoginPage(mySitePosition: Int) = launch { - _startLoginFlow.value = Event(Unit) - appPrefsWrapper.setMainPageIndex(mySitePosition) - delay(SWITCH_TO_MY_SITE_DELAY) - _switchToMySite.value = Event(Unit) + fun onOpenLoginPage() = launch { + _switchToMeTab.value = Event(Unit) } fun onResume(site: SiteModel?, isOnMySitePageWithValidSite: Boolean) { diff --git a/WordPress/src/main/res/color/on_surface_on_primary_surface_selector.xml b/WordPress/src/main/res/color/on_surface_text_button_selector.xml similarity index 50% rename from WordPress/src/main/res/color/on_surface_on_primary_surface_selector.xml rename to WordPress/src/main/res/color/on_surface_text_button_selector.xml index 1a5a0fe3c9ad..b4ee9ce42b5a 100644 --- a/WordPress/src/main/res/color/on_surface_on_primary_surface_selector.xml +++ b/WordPress/src/main/res/color/on_surface_text_button_selector.xml @@ -2,8 +2,7 @@ - - + diff --git a/WordPress/src/main/res/color/reader_button_primary_background_selector.xml b/WordPress/src/main/res/color/reader_button_primary_background_selector.xml new file mode 100644 index 000000000000..e3bc2ae8f122 --- /dev/null +++ b/WordPress/src/main/res/color/reader_button_primary_background_selector.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/WordPress/src/main/res/color/reader_interest_filter_chip_background_selector.xml b/WordPress/src/main/res/color/reader_interest_filter_chip_background_selector.xml new file mode 100644 index 000000000000..059396280e84 --- /dev/null +++ b/WordPress/src/main/res/color/reader_interest_filter_chip_background_selector.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/WordPress/src/main/res/color/reader_interest_filter_chip_ripple_selector.xml b/WordPress/src/main/res/color/reader_interest_filter_chip_ripple_selector.xml new file mode 100644 index 000000000000..a31c392b877a --- /dev/null +++ b/WordPress/src/main/res/color/reader_interest_filter_chip_ripple_selector.xml @@ -0,0 +1,4 @@ + + + + diff --git a/WordPress/src/main/res/color/reader_interest_filter_chip_stroke_selector.xml b/WordPress/src/main/res/color/reader_interest_filter_chip_stroke_selector.xml new file mode 100644 index 000000000000..8937bde80735 --- /dev/null +++ b/WordPress/src/main/res/color/reader_interest_filter_chip_stroke_selector.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/WordPress/src/main/res/color/reader_interest_filter_chip_text_selector.xml b/WordPress/src/main/res/color/reader_interest_filter_chip_text_selector.xml new file mode 100644 index 000000000000..7fa161b638f4 --- /dev/null +++ b/WordPress/src/main/res/color/reader_interest_filter_chip_text_selector.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/WordPress/src/main/res/drawable-mdpi/illustration_reader_empty.png b/WordPress/src/main/res/drawable-mdpi/illustration_reader_empty.png new file mode 100644 index 000000000000..df115c8af807 Binary files /dev/null and b/WordPress/src/main/res/drawable-mdpi/illustration_reader_empty.png differ diff --git a/WordPress/src/main/res/drawable-xhdpi/illustration_reader_empty.png b/WordPress/src/main/res/drawable-xhdpi/illustration_reader_empty.png new file mode 100644 index 000000000000..20c0701d0550 Binary files /dev/null and b/WordPress/src/main/res/drawable-xhdpi/illustration_reader_empty.png differ diff --git a/WordPress/src/main/res/drawable-xxhdpi/illustration_reader_empty.png b/WordPress/src/main/res/drawable-xxhdpi/illustration_reader_empty.png new file mode 100644 index 000000000000..d5fed41fbd7d Binary files /dev/null and b/WordPress/src/main/res/drawable-xxhdpi/illustration_reader_empty.png differ diff --git a/WordPress/src/main/res/drawable-xxxhdpi/illustration_reader_empty.png b/WordPress/src/main/res/drawable-xxxhdpi/illustration_reader_empty.png new file mode 100644 index 000000000000..9f8275003af7 Binary files /dev/null and b/WordPress/src/main/res/drawable-xxxhdpi/illustration_reader_empty.png differ diff --git a/WordPress/src/main/res/drawable/ic_magnifying_glass_16dp.xml b/WordPress/src/main/res/drawable/ic_magnifying_glass_16dp.xml new file mode 100644 index 000000000000..5d692bc41d3d --- /dev/null +++ b/WordPress/src/main/res/drawable/ic_magnifying_glass_16dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/WordPress/src/main/res/drawable/ic_reader_discover_24dp.xml b/WordPress/src/main/res/drawable/ic_reader_discover_24dp.xml new file mode 100644 index 000000000000..dcf730e345a4 --- /dev/null +++ b/WordPress/src/main/res/drawable/ic_reader_discover_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/WordPress/src/main/res/drawable/ic_reader_liked_24dp.xml b/WordPress/src/main/res/drawable/ic_reader_liked_24dp.xml new file mode 100644 index 000000000000..21d8baefaf70 --- /dev/null +++ b/WordPress/src/main/res/drawable/ic_reader_liked_24dp.xml @@ -0,0 +1,11 @@ + + + diff --git a/WordPress/src/main/res/drawable/ic_reader_saved_24dp.xml b/WordPress/src/main/res/drawable/ic_reader_saved_24dp.xml new file mode 100644 index 000000000000..d4af91c65c4b --- /dev/null +++ b/WordPress/src/main/res/drawable/ic_reader_saved_24dp.xml @@ -0,0 +1,10 @@ + + + diff --git a/WordPress/src/main/res/drawable/ic_reader_subscriptions_24dp.xml b/WordPress/src/main/res/drawable/ic_reader_subscriptions_24dp.xml new file mode 100644 index 000000000000..c96edaecf536 --- /dev/null +++ b/WordPress/src/main/res/drawable/ic_reader_subscriptions_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/WordPress/src/main/res/drawable/ic_small_chevron_down_white_16dp.xml b/WordPress/src/main/res/drawable/ic_small_chevron_down_white_16dp.xml new file mode 100644 index 000000000000..4671a193e5f0 --- /dev/null +++ b/WordPress/src/main/res/drawable/ic_small_chevron_down_white_16dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/WordPress/src/main/res/drawable/img_illustration_following_empty_results_196dp.xml b/WordPress/src/main/res/drawable/img_illustration_following_empty_results_196dp.xml deleted file mode 100644 index c1d55254ef4a..000000000000 --- a/WordPress/src/main/res/drawable/img_illustration_following_empty_results_196dp.xml +++ /dev/null @@ -1,70 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/WordPress/src/main/res/layout/notification_action_menu.xml b/WordPress/src/main/res/layout/notification_action_menu.xml new file mode 100644 index 000000000000..165f25309025 --- /dev/null +++ b/WordPress/src/main/res/layout/notification_action_menu.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/WordPress/src/main/res/layout/notification_actions.xml b/WordPress/src/main/res/layout/notification_actions.xml new file mode 100644 index 000000000000..d9beae2543ea --- /dev/null +++ b/WordPress/src/main/res/layout/notification_actions.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + diff --git a/WordPress/src/main/res/layout/reader_activity_subs.xml b/WordPress/src/main/res/layout/reader_activity_subs.xml index f609dde21a85..15b65f4dd241 100644 --- a/WordPress/src/main/res/layout/reader_activity_subs.xml +++ b/WordPress/src/main/res/layout/reader_activity_subs.xml @@ -20,7 +20,7 @@ style="@style/TextAppearance.App.Toolbar.Title" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:text="@string/reader_title_subs" + android:text="@string/reader_activity_title_manage_tags_blogs" android:textColor="?attr/colorOnSurface" /> @@ -74,7 +74,7 @@ android:layout_height="wrap_content" android:layout_alignParentStart="true" android:layout_toStartOf="@+id/btn_add" - android:hint="@string/reader_hint_add_tag_or_url" + android:hint="@string/reader_hint_add_tag_or_url_subscribe" android:minHeight="@dimen/min_touch_target_sz" android:paddingStart="@dimen/margin_large" android:paddingEnd="@dimen/margin_medium" diff --git a/WordPress/src/main/res/layout/reader_discover_fragment_layout.xml b/WordPress/src/main/res/layout/reader_discover_fragment_layout.xml index 27d63b70dec5..66f22aa0b70a 100644 --- a/WordPress/src/main/res/layout/reader_discover_fragment_layout.xml +++ b/WordPress/src/main/res/layout/reader_discover_fragment_layout.xml @@ -34,10 +34,11 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:visibility="gone" - app:aevButton="@string/reader_discover_no_posts_button_text" - app:aevImage="@drawable/img_illustration_empty_results_216dp" - app:aevSubtitle="@string/reader_discover_no_posts_subtitle" + app:aevButton="@string/reader_discover_no_posts_button_tags_text" + app:aevImage="@drawable/illustration_reader_empty" + app:aevSubtitle="@string/reader_discover_no_posts_subscribe_subtitle" app:aevTitle="@string/reader_discover_no_posts_title" + app:aevButtonStyle="reader" tools:visibility="visible" /> diff --git a/WordPress/src/main/res/layout/reader_fragment_layout.xml b/WordPress/src/main/res/layout/reader_fragment_layout.xml index 0e1e36762769..64d03853edf3 100644 --- a/WordPress/src/main/res/layout/reader_fragment_layout.xml +++ b/WordPress/src/main/res/layout/reader_fragment_layout.xml @@ -10,29 +10,19 @@ android:layout_width="match_parent" android:layout_height="wrap_content"> - - - + android:layout_height="wrap_content" + app:layout_scrollFlags="scroll|enterAlways" /> - + app:layout_behavior="@string/appbar_scrolling_view_behavior"/> diff --git a/WordPress/src/main/res/layout/reader_fragment_post_cards.xml b/WordPress/src/main/res/layout/reader_fragment_post_cards.xml index 89f3f7860663..3e17c47c1f79 100644 --- a/WordPress/src/main/res/layout/reader_fragment_post_cards.xml +++ b/WordPress/src/main/res/layout/reader_fragment_post_cards.xml @@ -6,19 +6,11 @@ android:layout_width="match_parent" android:layout_height="match_parent"> - - - - - - - - - diff --git a/WordPress/src/main/res/layout/reader_interest_filter_chip.xml b/WordPress/src/main/res/layout/reader_interest_filter_chip.xml index 6aeb511c0e0d..4b9409c7059a 100644 --- a/WordPress/src/main/res/layout/reader_interest_filter_chip.xml +++ b/WordPress/src/main/res/layout/reader_interest_filter_chip.xml @@ -2,5 +2,5 @@ diff --git a/WordPress/src/main/res/layout/reader_interests_fragment_layout.xml b/WordPress/src/main/res/layout/reader_interests_fragment_layout.xml index 96546afa2bb4..519613821ede 100644 --- a/WordPress/src/main/res/layout/reader_interests_fragment_layout.xml +++ b/WordPress/src/main/res/layout/reader_interests_fragment_layout.xml @@ -6,7 +6,7 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:background="?attr/colorSurface" - android:contentDescription="@string/reader_choose_interests_content_description"> + android:contentDescription="@string/reader_choose_interests_tags_content_description"> + app:aevSubtitle="@string/reader_no_blog_to_reblog_detail" + app:aevTitle="@string/reader_no_blog_to_reblog_title" /> diff --git a/WordPress/src/main/res/layout/reader_popup_menu_item.xml b/WordPress/src/main/res/layout/reader_popup_menu_item.xml index afc120464def..60d32d5f6e76 100644 --- a/WordPress/src/main/res/layout/reader_popup_menu_item.xml +++ b/WordPress/src/main/res/layout/reader_popup_menu_item.xml @@ -25,6 +25,6 @@ android:gravity="center_vertical" android:minHeight="@dimen/menu_item_height" android:textAppearance="?textAppearanceSmallPopupMenu" - tools:text="@string/reader_btn_follow" + tools:text="@string/reader_btn_subscribe" tools:textColor="@color/primary" /> diff --git a/WordPress/src/main/res/layout/reader_recommended_blog_item.xml b/WordPress/src/main/res/layout/reader_recommended_blog_item.xml index ba7ff4fe6ba4..4dcf2bcb37d8 100644 --- a/WordPress/src/main/res/layout/reader_recommended_blog_item.xml +++ b/WordPress/src/main/res/layout/reader_recommended_blog_item.xml @@ -97,7 +97,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginStart="@dimen/margin_small" - android:contentDescription="@string/reader_btn_follow" + android:contentDescription="@string/reader_btn_subscribe" app:layout_constraintBottom_toBottomOf="@id/site_url" app:layout_constraintEnd_toEndOf="@id/guideline_end" app:layout_constraintTop_toTopOf="@id/site_name" /> diff --git a/WordPress/src/main/res/layout/reader_recommended_blog_item_new.xml b/WordPress/src/main/res/layout/reader_recommended_blog_item_new.xml index 24b7b9174feb..54ed1bc6c9ae 100644 --- a/WordPress/src/main/res/layout/reader_recommended_blog_item_new.xml +++ b/WordPress/src/main/res/layout/reader_recommended_blog_item_new.xml @@ -60,7 +60,7 @@ style="@style/Reader.Follow.Button.New" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:contentDescription="@string/reader_btn_follow" + android:contentDescription="@string/reader_btn_subscribe" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toTopOf="parent" /> diff --git a/WordPress/src/main/res/layout/reader_recommended_blogs_card.xml b/WordPress/src/main/res/layout/reader_recommended_blogs_card.xml index 6f1023b95c39..0f73ecde117f 100644 --- a/WordPress/src/main/res/layout/reader_recommended_blogs_card.xml +++ b/WordPress/src/main/res/layout/reader_recommended_blogs_card.xml @@ -32,7 +32,7 @@ android:layout_marginEnd="@dimen/margin_extra_large" android:layout_marginStart="@dimen/margin_extra_large" android:layout_marginTop="@dimen/margin_extra_large" - android:text="@string/reader_discover_recommended_blogs_header" + android:text="@string/reader_discover_recommended_blogs_to_subscribe_header" app:layout_constraintLeft_toLeftOf="parent" app:layout_constraintRight_toRightOf="parent" app:layout_constraintTop_toTopOf="parent" /> diff --git a/WordPress/src/main/res/layout/subfilter_bottom_sheet.xml b/WordPress/src/main/res/layout/subfilter_bottom_sheet.xml index b378fe80bfd2..b98a83e64ef2 100644 --- a/WordPress/src/main/res/layout/subfilter_bottom_sheet.xml +++ b/WordPress/src/main/res/layout/subfilter_bottom_sheet.xml @@ -1,10 +1,9 @@ - - + android:layout_height="wrap_content" + android:orientation="horizontal" + android:gravity="center_vertical"> - + - + + + diff --git a/WordPress/src/main/res/layout/subfilter_component.xml b/WordPress/src/main/res/layout/subfilter_component.xml deleted file mode 100644 index 4331eedd823f..000000000000 --- a/WordPress/src/main/res/layout/subfilter_component.xml +++ /dev/null @@ -1,71 +0,0 @@ - - - - - - - - - - - - - - diff --git a/WordPress/src/main/res/layout/subfilter_list_item.xml b/WordPress/src/main/res/layout/subfilter_list_item.xml index fed9b0641267..ed6b501dcada 100644 --- a/WordPress/src/main/res/layout/subfilter_list_item.xml +++ b/WordPress/src/main/res/layout/subfilter_list_item.xml @@ -5,15 +5,29 @@ style="@style/SubfilterSiteTagItem" android:layout_width="match_parent"> + + @@ -23,11 +37,13 @@ style="@style/SiteTagFilteredUrl" android:layout_width="0dp" android:layout_height="wrap_content" + android:layout_marginStart="@dimen/margin_large" app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toStartOf="@+id/unseen_count" - app:layout_constraintStart_toStartOf="parent" + app:layout_constraintStart_toEndOf="@id/item_avatar" app:layout_constraintTop_toBottomOf="@+id/item_title" android:visibility="visible" + app:layout_goneMarginStart="0dp" app:layout_goneMarginEnd="0dp" tools:text="www.unknown.com" /> diff --git a/WordPress/src/main/res/layout/subfilter_page_fragment.xml b/WordPress/src/main/res/layout/subfilter_page_fragment.xml index cd27fc94036c..9f91eb5bf1cf 100644 --- a/WordPress/src/main/res/layout/subfilter_page_fragment.xml +++ b/WordPress/src/main/res/layout/subfilter_page_fragment.xml @@ -21,32 +21,52 @@ android:layout_height="wrap_content" android:orientation="vertical" android:visibility="gone" - tools:visibility="visible" - android:importantForAccessibility="yes"> + android:importantForAccessibility="yes" + android:padding="@dimen/margin_extra_large" + tools:visibility="visible"> + android:layout_marginBottom="@dimen/margin_medium" + android:text="@string/reader_filter_empty_tags_list_title" + app:fixWidowWords="true" /> - + + + + + android:text="@string/reader_filter_empty_tags_action_subscribe" /> diff --git a/WordPress/src/main/res/layout/tab_custom_view.xml b/WordPress/src/main/res/layout/tab_custom_view.xml deleted file mode 100644 index c36ba8e68e05..000000000000 --- a/WordPress/src/main/res/layout/tab_custom_view.xml +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - diff --git a/WordPress/src/main/res/menu/notifications_list_menu.xml b/WordPress/src/main/res/menu/notifications_list_menu.xml index d721396ead78..7f226358f32c 100644 --- a/WordPress/src/main/res/menu/notifications_list_menu.xml +++ b/WordPress/src/main/res/menu/notifications_list_menu.xml @@ -1,11 +1,9 @@ - + xmlns:app="http://schemas.android.com/apk/res-auto"> - diff --git a/WordPress/src/main/res/menu/reader_home.xml b/WordPress/src/main/res/menu/reader_home.xml deleted file mode 100644 index 48241de7a04a..000000000000 --- a/WordPress/src/main/res/menu/reader_home.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - diff --git a/WordPress/src/main/res/values-ar/strings.xml b/WordPress/src/main/res/values-ar/strings.xml index bc2a65e86636..726723533914 100644 --- a/WordPress/src/main/res/values-ar/strings.xml +++ b/WordPress/src/main/res/values-ar/strings.xml @@ -29,8 +29,6 @@ Language: ar انشر ردك. استقبل موجّهًا جديدًا لإلهامك في كل يوم. في شهر يناير، ستأتي موجّهات التدوين من Bloganuary - تحدٍّ مجتمعي لدينا لإنشاء عادة التدوين الخاصة بالعام الجديد. - لا، توقف عن الاستخدام - نعم، واصل الاستخدام لهذا السبب، نوصي بتحرير المكوّن باستخدام متصفح الويب لديك. لهذا السبب، نوصي بتحرير المكوّن باستخدام محرر الويب. بدلاً من ذلك، يمكنك تمهيد المحتوى عن طريق فك تجميع المكوّن. @@ -1882,12 +1880,10 @@ Language: ar %sQa %sT %sQi - المواقع تم الحفظ اكتشاف الإعجابات يتعذر علينا تحميل بيانات موقعك الآن. يرجى المحاولة مجددًا في وقت لاحق - المواضيع %sK مكتبة وسائط ووردبريس بدء الكتابة… diff --git a/WordPress/src/main/res/values-cs/strings.xml b/WordPress/src/main/res/values-cs/strings.xml index f644591552d7..0611ede12466 100644 --- a/WordPress/src/main/res/values-cs/strings.xml +++ b/WordPress/src/main/res/values-cs/strings.xml @@ -1602,8 +1602,6 @@ Language: cs_CZ %sT %sQa %sQi - Weby - Témata Objevujte Líbí se Uloženo diff --git a/WordPress/src/main/res/values-de/strings.xml b/WordPress/src/main/res/values-de/strings.xml index a6041634c997..ebab607238c9 100644 --- a/WordPress/src/main/res/values-de/strings.xml +++ b/WordPress/src/main/res/values-de/strings.xml @@ -34,8 +34,6 @@ Language: de Bloganuary Im Januar erhältst du Blog-Schreibanregungen vom Bloganuary, unserer Community-Challenge. Diese soll dir helfen, Bloggen im neuen Jahr zur Gewohnheit zu machen. Der Bloganuary kommt! - Nein, deaktivieren - Ja, aktiviert lassen Aus diesem Grund empfehlen wir, den Block in deinem Webbrowser zu bearbeiten. Aus diesem Grund empfehlen wir, den Block im Webeditor zu bearbeiten. Alternativ kannst du die Tiefe des Inhalts reduzieren, indem du die Gruppierung des Blocks aufhebst. @@ -50,8 +48,6 @@ Language: de Blöcke, die tiefer als %d Ebenen verschachtelt sind, werden im mobilen Editor möglicherweise nicht richtig dargestellt. Los geht\'s Es ist an der Zeit, deine WordPress-Reise in der Jetpack-App fortzusetzen. - Die Bildoptimierung schrumpft Bilder für schnelleres Hochladen.\n\nDiese Option ist standardmäßig aktiviert, aber du kannst jederzeit Änderungen in den App-Einstellungen vornehmen. - Sollen Bilder weiterhin optimiert werden? Suche löschen Sehr hoch Bitte gib deinen Sicherheitsschlüssel an, um fortzufahren. @@ -1890,8 +1886,6 @@ Language: de Likes Entdecken Gespeichert - Themen - Websites %sQi %sQa %sT diff --git a/WordPress/src/main/res/values-el/strings.xml b/WordPress/src/main/res/values-el/strings.xml index b63b80a5d756..996a1d95b573 100644 --- a/WordPress/src/main/res/values-el/strings.xml +++ b/WordPress/src/main/res/values-el/strings.xml @@ -67,9 +67,7 @@ Language: el_GR Δημοσίευση -%s Σελίδες του ιστότοπου - Ιστότοποι Αποθηκεύτηκε - Θέματα Λήψη βίντεο Λήψη φωτογραφίας Ξεκινήστε να γράφετε … diff --git a/WordPress/src/main/res/values-en-rCA/strings.xml b/WordPress/src/main/res/values-en-rCA/strings.xml index 6e2422d699f5..17018b852bbd 100644 --- a/WordPress/src/main/res/values-en-rCA/strings.xml +++ b/WordPress/src/main/res/values-en-rCA/strings.xml @@ -1843,13 +1843,11 @@ Language: en_CA %sQa %sT %sQi - Sites Saved Discover Likes We cannot load the data for your site right now. Please try again later %sK - Topics WordPress Media Library Unsupported Ungroup diff --git a/WordPress/src/main/res/values-en-rGB/strings.xml b/WordPress/src/main/res/values-en-rGB/strings.xml index 6ee3c41bdc2d..c185a2fe1627 100644 --- a/WordPress/src/main/res/values-en-rGB/strings.xml +++ b/WordPress/src/main/res/values-en-rGB/strings.xml @@ -34,8 +34,6 @@ Language: en_GB Bloganuary For the month of January, blogging prompts will come from Bloganuary – our community challenge to build a blogging habit for the new year. Bloganuary is coming! - No, turn off - Yes, leave on For this reason, we recommend editing the block using your web browser. For this reason, we recommend editing the block using the web editor. Alternatively, you can flatten the content by ungrouping the block. @@ -50,8 +48,6 @@ Language: en_GB Blocks nested deeper than %d levels may not render properly in the mobile editor. Let\'s go It\'s time to continue your WordPress journey on the Jetpack app. - Image optimisation shrinks images for faster uploading.\n\nThis option is enabled by default, but you can change it in the app settings at any time. - Keep optimising images? Clear search Very High Please provide your security key to continue. @@ -1858,7 +1854,7 @@ Language: en_GB high medium low -   & %1$d %2$s + & %1$d %2$s %1$s, %2$d %3$s Gallery caption. %s Create a post or page @@ -1890,8 +1886,6 @@ Language: en_GB Likes Discover Saved - Topics - Sites %sQi %sQa %sT diff --git a/WordPress/src/main/res/values-es-rCO/strings.xml b/WordPress/src/main/res/values-es-rCO/strings.xml index b3c8d47f5c84..68f4fe0608c3 100644 --- a/WordPress/src/main/res/values-es-rCO/strings.xml +++ b/WordPress/src/main/res/values-es-rCO/strings.xml @@ -1,6 +1,6 @@ Traballo sen conexión - Tamaño da fonte, %1$s Conexión de rede restablecida Conexión de rede perdida, traballando sen conexión + Tamaño da fonte, %1$s Tipo de arquivo non admitido como arquivo de medios. - %s - Nunca caduca - Dominio principal - Mellora a un plan - Outros dominios para %s - Simplemente busca un dominio - O teu dominio gratuíto de WordPress.com Non podemos abrir as páxinas neste momento. Por favor, inténtao de novo máis tarde + Simplemente busca un dominio + Mellora a un plan Rexistra ou transfire un dominio gratis durante un ano con calquera plan de pago anual. + Nunca caduca + O teu dominio gratuíto de WordPress.com + Outros dominios para %s + Dominio principal + %s Bloganuary xa está aquí! Imos aló! + Activa as suxestións de publicación Bloganuary utilizará as suxerencias de publicación diarias para enviarche temas durante o mes de xaneiro. Bloganuary usará as suxerencias diarias de publicación para enviarche temas para o mes de xaneiro. Actualmente tes desactivadas as suxerencias de publicación. - Activa as suxestións de publicación Le as respostas doutros blogueiros para conseguir inspiración e facer novas conexións. - Bloganuary - Bloganuary está á volta da esquina! Publica a tú resposta. - Únete ao noso reto de escritura dun mes Recibe unha suxerencia nova para inspirarte cada día. + Únete ao noso reto de escritura dun mes + Bloganuary Durante o mes de xaneiro, as suxerencias para escribir no blog provirán de Bloganuary, o noso reto comunitario para crear un hábito de blogueo para o novo ano. - Non, apágaa - Si, déixaa acendida - Conceder - Cancelar - Ir aos axustes - Tamén podes aplanar o contido desagrupando o bloque. - Por este motivo, recomendámosche que edites o bloque utilizando o editor web. + Bloganuary está á volta da esquina! Por esta razón, recomendámosche que edites o bloque utilizando o teu navegador web. + Por este motivo, recomendámosche que edites o bloque utilizando o editor web. + Tamén podes aplanar o contido desagrupando o bloque. + Ir aos axustes + Cancelar + Conceder Denegaches de forma permanente o permiso da cámara. É necesario para escanear o código de barras. Actívao nos axustes da aplicación - Escanear o código de barras + Necesítase o permiso da cámara para escanear o código de barras Conceder permiso á cámara Necesítase o permiso da cámara para escanear o código de barras. - Necesítase o permiso da cámara para escanear o código de barras + Escanear o código de barras É posible que os bloques aniñados a máis de %d niveis non se mostren correctamente no editor móbil. Imos - Baleirar a busca - Seguir optimizando as imaxes? É hora de continuar a túa xornada de WordPress na aplicación Jetpack. - A optimización de imaxes redúceas para subilas máis rápido.\n\nEsta opción está activada por defecto, pero podes cambiala nos axustes da aplicación en calquera momento. + Baleirar a busca Moi alta - Usa unha clave de seguridade Introduce a túa clave de seguridade para continuar. Houbo algúns problemas coa clave de seguridade de inicio de sesión - OK - Todos - Erro + Usa unha clave de seguridade + Non se puideron recuperar os dominios + %s durante o primeiro ano %s / ano - Dominio do sitio Transferir dominio - Buscar un dominio - %s durante o primeiro ano - De <b>Bloganuary</b> - Non se puideron recuperar os dominios - Teclea para obter máis suxerencias - Non se puideron recuperar os teus dominios Queres transferir un dominio que xa tes? + Teclea para obter máis suxerencias + Buscar un dominio + OK Algo saíu mal ao engadir o dominio ao carriño. Asegúrate de que estás conectado e volve a intentalo. + Erro + Todos + Non se puideron recuperar os teus dominios + Dominio do sitio + De <b>Bloganuary</b> Editado Filtrar por autor Bloque \'%s\' convertido a bloques @@ -92,12 +88,12 @@ Language: gl_ES Busca un dominio Toca a continuación para atopar o teu dominio perfecto. Non tes ningún dominio + Comproba que estás en liña e tira para actualizar. Abre os detalles do dominio Busca nos teus dominios Comprar un dominio - Caduca o %1$s - Comproba que estás en liña e tira para actualizar. Todos os dominios + Caduca o %1$s Conta e axustes Selecciona un plan Gratis durante o primeiro ano cos plans de pago anuais @@ -127,13 +123,13 @@ Language: gl_ES Borradores de publicacións Vistas, visitantes e gústame As tarxetas poden amosar contido diferente dependendo do que este a suceder co teu sitio + Engadir ou ocultar tarxetas + Personaliza a pestana de inicio Toca para personalizar a túa pestana de inicio Personaliza a túa pestana de inicio Cambiar os axustes Selecciona Máis Só están dispoñibles as fotos e os vídeos seleccionados aos que deches acceso. - Engadir ou ocultar tarxetas - Personaliza a pestana de inicio Ver todas as campañas Toda a actividade Todas as páxinas @@ -148,69 +144,69 @@ Language: gl_ES Accede a este bloque de Paywall no teu navegador web para axustes avanzados. Resposta: Pregunta: - Petición creada - Creando petición de asistencia… Transcrición do bot móbil de Jetpack: Erro ao enviar a petición de asistencia - Contactar co soporte técnico - Enviar unha mensaxe… + Petición creada + Creando petición de asistencia… + Como poido utilizar o meu dominio personalizado na aplicación? + Non me acordo dos meus datos de acceso Por que non poido acceder? - Non estás seguro/a de que preguntar? + Non poido subir fotos/vídeos Axuda, o meu sitio non funciona. Cal é o enderezo do meu sitio? - Non poido subir fotos/vídeos - Non me acordo dos meus datos de acceso - Como poido utilizar o meu dominio personalizado na aplicación? + Non estás seguro/a de que preguntar? + Contactar co soporte técnico En que che podo axudar? + Enviar unha mensaxe… Borrar %1$d compartidas en redes sociais restantes PECHAR - Contas conectadas - Compartir en redes sociais - Compartir en redes sociais - Redes sociais + Á app de WordPress fáltanlle compoñentes obrigatorios e debe ser reinstalada da Google Play Store. Instalación fallida Algo saíu mal Algo saíu mal Algo saíu mal, non se puideron obter as campañas - Á app de WordPress fáltanlle compoñentes obrigatorios e debe ser reinstalada da Google Play Store. - Personalizar a mensaxe - Agora non - Contas conectadas - Compartindo en %1$s - Non se compartirá en ningunha rede social + Contas conectadas + Compartir en redes sociais + Compartir en redes sociais + Redes sociais Compartindo en %1$d contas Compartindo en %1$d de %2$d contas + Compartindo en %1$s + Non se compartirá en ningunha rede social Personaliza a mensaxe que queres compartir. Se non engades o teu propio texto aquí, usaremos o título da entrada como mensaxe. + Personalizar a mensaxe + Agora non + Contas conectadas Insertar bloque de vídeo Insertar bloque de imaxe - Insertar bloque de audio Insertar bloque de galería + Insertar bloque de audio Crear + Aínda non creaches ningunha campaña. Fai clic en Crear para empezar. + Non tes campañas + Detalles da campaña + Campañas de Blaze + Orzamento Clics Impresións - Pechar o editor - Orzamento - ACTIVA + PROGRAMADA + EN MODERACIÓN CANCELADA REXEITADA - PROGRAMADA COMPLETADA - EN MODERACIÓN - Campaña de Blaze - Campañas de Blaze + ACTIVA Crear campaña - Detalles da campaña + Campaña de Blaze + Non se puido cargar o fluxo de promoción de Blaze + Aumenta o tráfico compartindo automaticamente as entradas cos teus amigos a través das redes sociais. + Pechar o editor Refacer o último cambio Desfacer o último cambio - Non tes campañas - Subscríbete agora para compartir máis 1 entrada compartida en redes sociais restante - Non se puido cargar o fluxo de promoción de Blaze - Aínda non creaches ningunha campaña. Fai clic en Crear para empezar. - Aumenta o tráfico compartindo automaticamente as entradas cos teus amigos a través das redes sociais. - Compartir en redes sociais + Subscríbete agora para compartir máis Aumenta o tráfico compartindo automaticamente as entradas cos teus amigos a través das redes sociais. + Compartir en redes sociais %s separado A edición de padróns sincronizados aínda non está incluída en %s para iOS A edición de padróns sincronizados aínda non está incluída en %s para Android @@ -218,94 +214,94 @@ Language: gl_ES Produciuse un erro ao gardar as túas opcións de privacidade. Gardar Axustes + Permítenos optimizar o rendemento mediante a recompilación de información sobre a forma na que os usuarios interactúan coas nosas aplicacións para móbiles. Analítica Xestión da privacidade - Eu. Xestionar os detalles do teu perfil. - Permítenos optimizar o rendemento mediante a recompilación de información sobre a forma na que os usuarios interactúan coas nosas aplicacións para móbiles. A túa privacidade é extremamente importante para nós e sempre o foi. Utilizamos, almacenamos e procesamos os teus datos persoais para optimizar a nosa aplicación (e a túa experiencia) de diversas maneiras. Algúns usos dos teus datos son absolutamente necesarios para que as cousas funcionen, e outros podes personalizalos nos Axustes. + Eu. Xestionar os detalles do teu perfil. Mensaxe - Aprende máis sobre os modelos - Páxina de inicio - Bloque agrupado Bloque desagrupado - Pechouse a conta. + Bloque agrupado + O dominio pode tardar ata 30 minutos en empezar a funcionar correctamente. + O teu novo dominio <b>%s</b> estase configurando. Todo listo! + Obtén un dominio gratuíto durante o primeiro ano, elimina os anuncios do teu sitio e aumenta o teu espazo de almacenamento. Consigue un dominio gratis cun plan anual - O teu novo dominio <b>%s</b> estase configurando. - O dominio pode tardar ata 30 minutos en empezar a funcionar correctamente. + Aprende máis sobre os modelos A túa páxina de inicio usa un modelo de tema, polo que se abrirá no editor web. - Obtén un dominio gratuíto durante o primeiro ano, elimina os anuncios do teu sitio e aumenta o teu espazo de almacenamento. + Páxina de inicio + Pechouse a conta. Ocorreu un erro ao pechar a conta. Non é posible pechar a conta deste usuario porque ten compras activas. Non é posible pechar a conta deste usuario porque ten subscricións activas. - Confirmar o peche da conta… Non é posible pechar a conta deste usuario se ten cargos rexeitados sen resolver. Non é posible pechar a conta deste usuario de inmediato porque ten compras activas. Contacta co noso equipo de soporte para eliminar a conta definitivamente. Non tes autorización para pechar a conta. Non se puido pechar a conta automaticamente! + Confirmar o peche da conta… Para confirmar, volve a introducir o teu nome de usuario antes de cerrala. Pechar conta Saber máis - A opción de compartir automaticamente en Twitter xa non está dispoñible O uso compartido automático de Twitter xa non está disponible debido aos cambios de Twitter nos termos e prezos. + A opción de compartir automaticamente en Twitter xa non está dispoñible A edición de bloques reutilizables aínda non é compatible con %s para iOS. A edición de bloques reutilizables aínda non é compatible con %s para Android. Permitir avisos para estar ao día co teu sitio A aplicación JetPack ten todas as funcionalidades da aplicación WordPress, e agora acceso exclusivo a Estatísticas, Lector, Avisos e máis. Usar WordPress con %s na aplicación\u00A0JetPack. Usar WordPress con %s na aplicación\u00A0JetPack. - Actividade recente - ONomeDaTuaWeb.com Cor sen etiquetar. %s + Actividade recente Como no exemplo superior, un dominio permítelle á xente encontrar e visitar o teu sitio desde o seu navegador. - o primeiro ano + ONomeDaTuaWeb.com Buscar con palabras clave - Enviámosche o teu recibo por correo electrónico. %s - Pode tardar ata 30 minutos en que o teu dominio personalizado empece a funcionar. Busca unha dominio curto e memorable para axudar á xente a encontrar e visitar o teu sitio. + o primeiro ano A túa web creouse correctamente, pero encontramos un problema ao preparar o teu dominio personalizado ao finalizar a compra. Por favor, inténtao de novo ou contacta co noso soporte para obter axuda. + Pode tardar ata 30 minutos en que o teu dominio personalizado empece a funcionar. + Enviámosche o teu recibo por correo electrónico. %s As notificacións da app desactiváronse. Pulsa aquí para activalas. - Xa non necesitas a aplicación WordPress no teu dispositivo - Parece que aínda tes a aplicación WordPress instalada. - Benvido á aplicación Jetpack. Podes desinstalar a aplicación WordPress. Recomendamos <b>desinstalar a aplicación WordPress</b> no teu dispositivo para evitar conflitos de datos. + Parece que aínda tes a aplicación WordPress instalada. + Xa non necesitas a aplicación WordPress no teu dispositivo Recomendamos <b>desinstalar a aplicación WordPress</b> no teu dispositivo para evitar conflitos de datos. + Benvido á aplicación Jetpack. Podes desinstalar a aplicación WordPress. Eliminar bloques Privacidade e valoracións - Manual - Dinámica Axustes de reprodución Cor da barra de reprodución + Manual + Dinámica + Describe o propósito da imaxe. Déixao baleiro se a imaxe é decorativa. + Comeza con deseños personalizados e preparados para dispositivos móbiles Crear outra páxina Engadir páxinas ao teu sitio - Comeza con deseños personalizados e preparados para dispositivos móbiles - Describe o propósito da imaxe. Déixao baleiro se a imaxe é decorativa. Para usar recordatorios para publicar, tes que activar os avisos instantáneos. + Activar os avisos instantáneos + Continuar con subdominio Comprar dominio + Fotos e vídeos, música e audio Música e audio Fotos e vídeos - Activar os avisos - Continuar con subdominio - Activar os avisos instantáneos - Fotos e vídeos, música e audio %s necesita permisos para acceder aos teus audios %s necesita permisos para acceder aos teus vídeos %s necesita permisos para acceder ás túas fotos %s necesita permisos para acceder ás túas fotos e vídeos %s necesita permisos para acceder á túa música, audios, fotos e vídeos + Activar os avisos Vai a Axustes &rarr; Notificacións &rarr; Axustes da app, e activa %1$s para recibir notificacións inmediatamente. - Corrección + Terás que abrir a aplicación para ver as notificacións. As notificacións push están desactivadas As notificacións push están desactivadas. Descarta o aviso do permiso de notificacións. - Terás que abrir a aplicación para ver as notificacións. + Corrección <b>%1$s</b> está usando %2$s plugins individuais de Jetpack <b>%1$s</b> está usando o plugin <b>%2$s</b> A aplicación de WordPress non é compatible cos plugins individuais de Jetpack. <b>%1$s</b> está usando plugins individuais de Jetpack que non son compatibles coa aplicación de WordPress. <b>%1$s</b> está usando o plugin <b>%2$s</b>, que non é compatible coa aplicación de WordPress. - Non se puido acceder a un dos teus sitios Non se puido acceder a algúns dos teus sitios + Non se puido acceder a un dos teus sitios Por favor, pásate á aplicación Jetpack, onde te guiaremos para que conectes o plugin Jetpack para usar este sitio coa aplicación. Cambia á aplicación de Jetpack %1$s usa %2$s, que aínda non é compatible con todas as funcións da aplicación.\n\nInstala o %3$s para usar a aplicación con este sitio. @@ -314,134 +310,134 @@ Language: gl_ES %1$s usa %2$s, que aínda non é compatible con todas as funcións da aplicación. Instala o %3$s. Pásate á aplicación de Jetpack en poucos días. O cambio é gratuíto e só che levará un minuto. - Feito + Encontrarás máis información en Jetpack.com + Cambia á aplicación de Jetpack + WP Admin Xestionar - Configurar Tráfico Contido - WP Admin - Encontrarás máis información en Jetpack.com - Cambia á aplicación de Jetpack + Configurar + Feito Agora que Jetpack está instalado, só tenemos que configuralo. Só che levará un minuto. - Fai un seguimento do rendemento, inicia e para a actividade promocional de Blaze en calquera momento. Promover unha entrada con Blaze agora Promover esta páxina con Blaze Promover esta entrada con Blaze + Fai un seguimento do rendemento, inicia e para a actividade promocional de Blaze en calquera momento. + O teu contido aparecerá en millóns de sitios de WordPress e Tumblr. + Promove calquera entrada ou páxina en cuestión de minutos por só uns euros ao día. + Xera máis tráfico cara o teu sitio con Blaze + Blaze + Este dominio xa está rexistrado Oferta Recomendado Mellor alternativa + ao ano Axuda - O teu contido aparecerá en millóns de sitios de WordPress e Tumblr. - Blaze - Este dominio xa está rexistrado - Xera máis tráfico cara o teu sitio con Blaze Consulta o noso FAQ para obter respostas a preguntas habituais que poderías ter. + Grazas por cambiar á aplicación de Jetpack! Rexistros Entradas Gratis Axuda - Grazas por cambiar á aplicación de Jetpack! - Promove calquera entrada ou páxina en cuestión de minutos por só uns euros ao día. - ao ano Menú de bloques + Amosa o teu traballo en millóns de sitios. + Promove o teu contido con Blaze Pechar Contactar con soporte - Termos e condicións Instalar o plugin completo + Termos e condicións Ao configurar Jetpack, aceptas os nosos - Amosa o teu traballo en millóns de sitios. - Promove o teu contido con Blaze plugin completo de Jetpack plugins individuais de Jetpack o plugin %1$s %1$s usa %2$s, que aínda non é compatible con todas as funcións da aplicación.\n\nInstala o %3$s para usar a aplicación con este sitio. - Contactar con soporte - Reintentar - Icona de erro Por favor, instala o plugin completo de Jetpack Só hai un sitio dispoñible, polo que non podes cambiar o teu sitio principal. + Contactar con soporte + Reintentar Jetpack non se puido instalar agora. Produciuse un problema - Promocionar con Blaze + Icona de erro + Todo listo para usar este sitio coa aplicación. Jetpack instalado Instalando Jetpack no teu sitio. Isto pode levar uns minutos completarse. Instalando Jetpack Continuar As credenciais da túa web non se almacenarán, e só se utilizan para instalar Jetpack. - Icona de Jetpack - Todo listo para usar este sitio coa aplicación. Instalar Jetpack + Icona de Jetpack + Promocionar con Blaze Libera todo o potencial do teu sitio. Obtén estatísticas, notificacións e máis con Jetpack. O teu sitio ten o plugin de Jetpack + A aplicación móbil Jetpack está deseñada para funcionar xunto co plugin de Jetpack. Fai o cambio agora e obtén acceso a estatísticas, notificacións e ao lector, entre outras funcións. Recibe notificacións por novos comentarios, Gústame, visualizacións, etc. - Consulta o crecemento do tráfico ao teu sitio con información útil e estatísticas completas. Comparte o teu contido e busca as túas comunidades e sitios favoritos para seguilos. - A aplicación móbil Jetpack está deseñada para funcionar xunto co plugin de Jetpack. Fai o cambio agora e obtén acceso a estatísticas, notificacións e ao lector, entre outras funcións. + Consulta o crecemento do tráfico ao teu sitio con información útil e estatísticas completas. Estatísticas e datos clave - Ocultáronse os estímulos para bloguear. + Con Jetpack sacarás máis partido do teu sitio de WordPress. O cambio é gratuíto e só che levará un minuto. Dálle un impulso a WordPress con Jetpack - Vai a <b>Axustes do sitio</b> para reactivalos. - Cada notificación incluirá unha palabra ou unha breve frase inspiradora. Podes xestionar os recordatorios e estímulos para bloguear en calquera momento desde O meu sitio > Axustes > Bloguear. - Con Jetpack sacarás máis partido do teu sitio de WordPress. O cambio é gratuíto e só che levará un minuto. - Bloguear - Amosar estímulos + Cada notificación incluirá unha palabra ou unha breve frase inspiradora. + Vai a <b>Axustes do sitio</b> para reactivalos. + Ocultáronse os estímulos para bloguear. Desactivar estímulos + Recibe axuda do noso grupo de voluntarios. Foros da comunidade Recordatorios de blogueo - Recibe axuda do noso grupo de voluntarios. + Amosar estímulos + Bloguear + Por favor, instala a Google Play Store para obter a app de Jetpack Facelo máis tarde Cambiar a Jetpack - %1$s trasladarase pronto - %1$s trasladaranse pronto - %1$s trasladarase a %2$s - %1$s trasladaranse a %2$s - As características de Jetpack trasladáronse. - Por favor, instala a Google Play Store para obter a app de Jetpack Algunhas características de Jetpack como Estatísticas, Lector ou Notificacións, entre outras, elimináronse da app de WordPress. - 1 semana - %d semanas - Últimos sete días - Sete días anteriores - Ver todas as respostas + As características de Jetpack trasladáronse. + %1$s trasladaranse a %2$s + %1$s trasladarase a %2$s + %1$s trasladaranse pronto + %1$s trasladarase pronto Obtén a aplicación de Jetpack + Ver todas as respostas %1$s é menor que a semana anterior %1$s é maior que a semana anterior - As túas visitas nos últimos sete días son %1$s menos que nos sete días anteriores. - As túas visitas nos últimos sete días son %1$s máis que nos sete días anteriores. Os teus visitantes nos últimos sete días son %1$s menos que nos sete días anteriores. Os teus visitantes nos últimos sete días son %1$s máis que nos sete días anteriores. + As túas visitas nos últimos sete días son %1$s menos que nos sete días anteriores. + As túas visitas nos últimos sete días son %1$s máis que nos sete días anteriores. + Sete días anteriores + Últimos sete días + %d semanas + 1 semana Desde <b>Day One</b> Recórdamo máis tarde + As estatísticas, o lector, as notificacións e outras características trasladaranse pronto á aplicación móbil de Jetpack. Cambiar á aplicación de Jetpack Máis información en jetpack.com O cambio é gratuíto e só leva un minuto. Vanse a retirar pronto da aplicación de WordPress as estatísticas, lectura, avisos e outras funcionalidades de Jetpack. Vanse a retirar da aplicación de WordPress as estatísticas, lectura, avisos e outras funcionalidades de Jetpack o %s. - As estatísticas, o lector, as notificacións e outras características trasladaranse pronto á aplicación móbil de Jetpack. - Vaia! - 1 resposta - 0 respostas - %d respostas - Aínda non hai suxerencias - Cambiar á nova aplicación de Jetpack As funcións de Jetpack trasladaranse pronto. Os avisos estanse trasladando á aplicación de Jetpack O lector estase trasladando á aplicación de Jetpack - Produciuse un erro ao cargar as indicacións. - Neste momento non se puido cargar este contido A estatísticas estanse trasladado á aplicación de Jetpack + Cambiar á nova aplicación de Jetpack Comproba a túa conexión á rede e inténtao de novo. - Peticións + Neste momento non se puido cargar este contido + Produciuse un erro ao cargar as indicacións. + Vaia! + Aínda non hai suxerencias + %d respostas + 1 resposta + 0 respostas ✓ Respondido + Peticións pechar Alternativamente, podes separar e editar este bloque por separado tocando en «Separar padrón». - Borrando a categoría que fallou - Categoría borrada correctamente Borrar permanentemente a categoría «%s»? - Actualizar categoría + Categoría borrada correctamente + Borrando a categoría que fallou Borrando a categoría Actualizando a categoría + Actualizar categoría As entradas deste usuario non se volverán a mostrar Bloquear usuario Informar deste usuario @@ -449,45 +445,45 @@ Language: gl_ES Parece que tes a aplicación de Jetpack instalada.\n\nQuixeras abrir as ligazóns na aplicación Jetpack a partir de agora?\n\nSempre podes modificar este comportamento en Configuración > Abrir ligazóns en Jetpack Abrir ligazóns en Jetpack? Continuar sen Jetpack - Jetpack fornece estatísticas, notificacións e moito máis para axudarche a crear e desenvolver o sitio WordPress dos teus soños. O Jetpack fornece estatísticas, notificacións e moito máis para axudarche a crear e desenvolver o sitio WordPress dos teus soños.\n\nA aplicación WordPress xa non é compatíbel coa creación de sitios novos. + Jetpack fornece estatísticas, notificacións e moito máis para axudarche a crear e desenvolver o sitio WordPress dos teus soños. Crear un novo sitio WordPress coa aplicación Jetpack - urilinks weblinks + urilinks Muda para a aplicación Jetpack para continuar a recibir notificacións en tempo real no seu dispositivo. Muda para a aplicación Jetpack para encontrar, seguir e gustar todas as túas publicacións e sitios favoritos co Lector. Muda para a aplicación Jetpack para observar o crecemento do tráfico do teu sitio con estatísticas e outros detalles. - De acordo - Necesitas axuda? - Abrir ligazóns en Jetpack + Recibe as túas notificacións coa aplicación de Jetpack Segue calquera sitio coa aplicación de Jetpack - Non se pode activar abrir as ligazóns en Jetpack - Non se pode desactivar abrir as ligazóns en Jetpack Obtén as túas estatísticas coa nova aplicación de Jetpack - Recibe as túas notificacións coa aplicación de Jetpack + Non se pode desactivar abrir as ligazóns en Jetpack + Non se pode activar abrir as ligazóns en Jetpack + Abrir ligazóns en Jetpack + Necesitas axuda? + De acordo + Non podemos transferir os teus datos e axustes sen unha conexión de rede. + Comproba a túa conexión de rede para asegurarte de que funcione e volve a intentalo. Non se puido conectar a Internet. Contacta co equipo de soporte ou inténtao de novo máis tarde. - Comproba a túa conexión de rede para asegurarte de que funcione e volve a intentalo. - Non podemos transferir os teus datos e axustes sen unha conexión de rede. Algo non foi como estaba previsto. Os teus datos están protexidos, pero non podemos transferilos neste momento. - Terminar + Vaia, produciuse un erro. Volver a intentalo + Terminar Icona para quitar a aplicación de WordPress - Vaia, produciuse un erro. Transferimos todos os teus datos e axustes. Todo está tal e como o deixaches. Grazas por cambiar a Jetpack! Desactivaremos as notificacións da aplicación de WordPress. Recibirás as mesmas notificacións, pero a partir de agora desde a aplicación de Jetpack. - Soporte Centro de axuda de WordPress - desactivar notificacións de WordPress + Soporte Permite que a aplicación desactive as notificacións de WordPress. - Continuar + desactivar notificacións de WordPress Necesitas axuda? - A túa foto de perfil - Parece que estas realizando o cambio desde a aplicación de WordPress. + Continuar Encontramos o teu sitio. Continúa para transferir todos os teus datos e acceder a Jetpack automaticamente. Encontramos os teus sitios. Continúa para transferir todos os teus datos e acceder a Jetpack automaticamente. + A túa foto de perfil + Parece que estas realizando o cambio desde a aplicación de WordPress. Dámosche a benvida a Jetpack! icona Páxina pai @@ -502,54 +498,54 @@ Language: gl_ES Estás gozando de %s? Comparte unha entrada en %s Conexións de Jetpack Social - Conexións de Jetpack Social Por favor, accede á aplicación Jetpack para engadir un widget. + Conexións de Jetpack Social Acabamos de enviar unha ligazón máxica a Revisa o teu correo electrónico neste dispositivo! Usar un contrasinal para acceder - As estatísticas funcionan con Jetpack - Reader funciona con Jetpack - Os avisos funcionan con Jetpack Mantente informado con actualizacións en tempo real para novos comentarios, tráfico do sitio, informes de seguridade e máis. - Encontra, segue e dálle «Gústame» a todos os teus sitios e publicacións favoritos con Reader, agora dispoñible na nova aplicación Jetpack. + Os avisos funcionan con Jetpack Observa como crece o teu tráfico e obtén información sobre a túa audiencia con estatísticas e información redeseñadas, agora dispoñibles na nova aplicación Jetpack. - WordPress é mellor con Jetpack + As estatísticas funcionan con Jetpack + Encontra, segue e dálle «Gústame» a todos os teus sitios e publicacións favoritos con Reader, agora dispoñible na nova aplicación Jetpack. + Reader funciona con Jetpack A nova aplicación Jetpack ten estatísticas, lector, notificacións e máis que melloran o teu WordPress. - Degradado - URL non válida. + WordPress é mellor con Jetpack + Actualiza o teu plan para usar fondos de vídeo + Actualiza o teu plan para subir audio Funciona grazas a Jetpack + URL non válida. + Degradado + Continuar aos avisos Continuar ás estatísticas Continuar ao lector - Continuar aos avisos - Actualiza o teu plan para subir audio - Actualiza o teu plan para usar fondos de vídeo Proba a nova aplicación de Jetpack - A semana pasada tiveches %1$s visitas. - A semana pasada tiveches %1$s visitas e %2$s Gústame + Problema ao mostrar o bloque. \nToca para intentar a recuperación do bloque. A semana pasada tiveches %1$s visitas e %2$s comentarios - ⭐️ A túa última entrada %1$s recibiu %2$s Gústame. + A semana pasada tiveches %1$s visitas e %2$s Gústame + A semana pasada tiveches %1$s visitas. A semana pasada tiveches %1$s visitas, %2$s Gústame e %3$s comentarios. - Problema ao mostrar o bloque. \nToca para intentar a recuperación do bloque. + ⭐️ A túa última entrada %1$s recibiu %2$s Gústame. Funciona grazas a Jetpack + Imaxe que sinala que o escaneo do código de acceso está en proceso Imaxe que sinala un erro - Saír do fluxo de escaneo de código de acceso Seguro que queres continuar? - Imaxe que sinala que o escaneo do código de acceso está en proceso + Saír do fluxo de escaneo de código de acceso Non se puido acceder con este código de acceso. Toca o botón Analizar de novo para volver a escanear o código. - Descartar - Analizar de novo - Non hai conexión - Si, quero acceder - Accediches! - O código de acceso caducou Fallou a autentificación + Este código de acceso caducou. Toca o botón Analizar de novo para volver a escanear o código. + O código de acceso caducou + Non se puido validar o código de acceso escaneado. Toca o botón Analizar de novo para volver a escanear o código. Non se puido validar o código de acceso - Estás intentando acceder ao teu navegador web cerca de %1$s? - Toca descartar e volve ao teu navegador web para continuar. Requírese unha conexión activa a Internet para escanear códigos de acceso - Este código de acceso caducou. Toca o botón Analizar de novo para volver a escanear o código. + Non hai conexión + Analizar de novo + Descartar + Toca descartar e volve ao teu navegador web para continuar. + Accediches! + Si, quero acceder Escanea só os códigos QR que colliches directamente do navegador web. Non escanees nunca un código que che enviara alguén. - Non se puido validar o código de acceso escaneado. Toca o botón Analizar de novo para volver a escanear o código. + Estás intentando acceder ao teu navegador web cerca de %1$s? Estás intentando acceder a %1$s cerca de %2$s? 💡Comentar noutros blogs é unha boa forma de chamar a atención e ter máis seguidores no teu novo sitio. 💡Toca «VER MÁIS» para ver os principais comentaristas. @@ -558,84 +554,84 @@ Language: gl_ES ✍️ Programa os teus borradores para publicar no mellor momento e chegar ao teu público. 💡Publicar con constancia é unha boa forma de crear o teu público. Engade un recordatorio para manterte ao día. 💡Bloguea máis rapidamente co noso curso <i>Introdución aos blogs</i> ofrecido por expertos. - Non podes decidirte? Podes cambiar o tema en calquera momento. Estanse cargando os estímulos para bloguear. Espera un momento e inténtao de novo. - Vistas - Totais - Outros - Buscar + Non podes decidirte? Podes cambiar o tema en calquera momento. Bloguear - Programar - WordPress - Máis información - Ideal para %s Elixido para ti - Elixe un tema + Ideal para %s Vista previa do tema %s + Elixe un tema Salteime o estímulo para bloguear de hoxe - Configurar recordatorios + Máis información + Totais + Outros + Buscar + WordPress + Vistas + Programar Programa a túa entrada - Consulta o curso + Configurar recordatorios Configura os teus recordatorios de blogueo + Consulta o curso Fai crecer a túa audiencia + Tamén podes reorganizar os bloques tocando un bloque e logo tocando as frechas arriba e abaixo que aparecen na parte inferior esquerda do bloque para movelo encima ou debaixo doutros bloques. + Arquivo de imaxe non encontrado. + Arrastrar e soltar fai que reordear bloques sexa algo trivial. Presiona e suxeita un bloque, logo arrástrao á súa nova ubicación e sóltao. Arrastrar e soltar Botóns de frechas + %1$s. Seleccionado actualmente: %2$s + Todas as tarefas están completas Tarefa completada Explorar código de acceso - Todas as tarefas están completas - Arquivo de imaxe non encontrado. - %1$s. Seleccionado actualmente: %2$s - Arrastrar e soltar fai que reordear bloques sexa algo trivial. Presiona e suxeita un bloque, logo arrástrao á súa nova ubicación e sóltao. - Tamén podes reorganizar os bloques tocando un bloque e logo tocando as frechas arriba e abaixo que aparecen na parte inferior esquerda do bloque para movelo encima ou debaixo doutros bloques. ⭐️ A túa última entrada %1$s recibiu %2$s gústame. Non hai suficiente actividade. Volve a comprobalo máis tarde, cando o teu sitio teña máis visitas! - %1$s (%2$s%%) %1$s, %2$s%% do total de seguidores + %1$s (%2$s%%) Copiar ligazón - Coñece a aplicación - Sube fotos ou vídeos Noraboa! Xa sabes manexarte<br/> + Coñece a aplicación Sube os medios directamente ao teu sitio desde o teu dispositivo ou cámara - Miniatura de vídeo - Principais comentaristas - Comproba os teus avisos + Sube fotos ou vídeos Obtén actualizacións en tempo real desde o teu peto - Obtén actualizacións en tempo real desde o teu peto. - Utiliza <b> Descubrir </b> para encontrar sitios e etiquetas. Selecciona %1$s Medios %2$s para ver a túa biblioteca actual. + Obtén actualizacións en tempo real desde o teu peto. + Comproba os teus avisos Selecciona a %1$s Pestana de avisos %2$s para recibir actualizacións sobre a marcha. Selecciona %1$s Máis %2$s para subir medios. Podes engadilos ás túas entradas ou páxinas desde calquera dispositivo. + Utiliza <b> Descubrir </b> para encontrar sitios e etiquetas. Utiliza <b> Descubrir </b> para encontrar sitios e etiquetas. Proba a seleccionar %1$s Axustes %2$s para engadir temáticas que che gusten. - Total de «Gústame» - Total de comentarios - Total de seguidores - Publicada fai un día + Miniatura de vídeo + Principais comentaristas + Publicada fai %1$d anos Publicada fai un ano + Publicada fai %1$d meses Publicada fai un mes - Publicada fai unha hora - Publicada fai uns segundos - Publicada fai un minuto Publicada fai %1$d días - Publicada fai %1$d anos + Publicada fai un día Publicada fai %1$d horas - Publicada fai %1$d meses + Publicada fai unha hora Publicada fai %1$d minutos + Publicada fai un minuto + Publicada fai uns segundos + Total de seguidores + Total de comentarios + Total de «Gústame» Descartar Resposta Estímulo diario Entendido Toca <b>%1$s</b> para ver o teu sitio Selecciona o %1$s Lector %2$s para descubrir outros sitios. + Aprende máis sobre os estímulos + Vídeo non seleccionado Vídeo seleccionado Miniatura do medio - Vídeo non seleccionado 🔥 A hora máis popular - Aprende máis sobre os estímulos %1$s %2$s Visitar o escritorio O teu sitio xa está protexido con VaultPress. Máis abaixo, podes encontrar unha ligazón ao teu escritorio de VaultPress. - Idioma actual: O teu sitio ten VaultPress + Idioma actual: Crear sitio Engadir bloques Pantalla inicial @@ -651,110 +647,110 @@ Language: gl_ES Establecer como imaxe destacada Manter actual Reemplazar a imaxe destacada - Descartar - Remprazamos a imaxe destacada actual? Xa tes establecida unha imaxe destacada. Queres remprazala coa nova imaxe? + Remprazamos a imaxe destacada actual? + Descartar Pronto eliminaremos o editor clásico para as novas entradas, pero isto non afectará á edición de ningunha das túas entradas ou páxinas existentes. Adiántate agora activando o editor de bloques nos axustes do sitio. + Proba o novo editor de bloques + Editar o bloque %s Gardando + Reintentar todo + Eliminar a subida + Reintentar + Non se puido subir o arquivo + Non Si Cancelar Aceptar - Non - Reintentar - Reintentar todo http(s):// - Editar o bloque %s - Eliminar a subida - Non se puido subir o arquivo - Proba o novo editor de bloques Inserir unha ligazón Beta - Aceptar - %dpx - Estrutura do contido O editor aínda está cargando - Elixe un medio da galería Fallo ao obter a estrutura do contido - Fai unha foto ou un vídeo coa cámara + Bloques: %1$d\nPalabras: %2$d\nCaracteres: %3$d + Estrutura do contido Elixe un medio da biblioteca de medios de WordPress + Elixe un medio da galería + Fai unha foto ou un vídeo coa cámara + %dpx + Aceptar Por favor, espera ata que se teñan gardado todos os arquivos - Bloques: %1$d\nPalabras: %2$d\nCaracteres: %3$d - Nota: + Arquivos gardándose Contido + Fai a película da túa vida. Recordarmo Próbao agora - Arquivos gardándose - Fai a película da túa vida. + Nota: Mostrarémosche un novo estímulo cada día no teu escritorio para axudarche a que flúan eses fluídos creativos! O mellor modo de converterte nun mellor escritor é crear un hábito de escritura e compartir con outros - aquí é onde entran os estímulos! - Configurar recordatorios - Publicar con regularidade atrae novos lectores. Cóntanos cando queres escribir e enviarémosche un recordatorio! Presentando\nIndicacións para bloguear + Configurar recordatorios Incluír as indicacións para bloguear - Viaxes - Deportes + Publicar con regularidade atrae novos lectores. Cóntanos cando queres escribir e enviarémosche un recordatorio! + Convértete nun mellor escritor creando un hábito + Escritura e poesía + Viaxes Tecnoloxía + Deportes Inmobiliaria - Escritura e poesía - Convértete nun mellor escritor creando un hábito - Bricolaxe + Política + Fotografía + Persoal + Xente + Paternidade Noticias - Comida Música - Xente + Servizos locais + Estilo de vida + Deseño de interiores Saúde Xogos + Comida + Forma física e exercicio + Películas e televisión Finanzas Moda - Política - Persoal - Paternidade - Estilo de vida + Bricolaxe Educación - Fotografía - Servizos locais - Deseño de interiores - Películas e televisión - Forma física e exercicio Comunitario e ONG - Arte + Negocios Libros Beleza - Negocios Automoción + Arte + P.ex.: Moda, poesía, política Temática do sitio - Ver máis estímulos Toca <b>%1$s</b> para continuar. - P.ex.: Moda, poesía, política Omitir hoxe + Ver máis estímulos %d respostas - ✓ Respondido Comparte o estímulo de bloguear - Todos - Estímulos + ✓ Respondido Responder estímulo + Estímulos + Todos Esta combinación de cor pode ser difícil de ler para a xente. Intenta usar unha cor de fondo máis clara e/ou unha cor de texto máis escura. Esta combinación de cor pode ser difícil de ler para a xente. Intenta usar unha cor de fondo máis escura e/ou unha cor de texto máis clara. Fallo ao inserir os medios.\nToca para máis información. - De que trata a túa web? Elixe unha temática das listadas a continuación ou escribe a túa propia. - Inicio + De que trata a túa web? Resumo semanal + Inicio Engadir categorías Que aplicación de correo electrónico usas? Houbo un problema ao comunicar co sitio. Devolveuse un código de erro HTTP 401. - Non se puido ler o sitio WordPress nesa URL. Toca na icona de axuda para ver as FAQ. As chamadas XML-RPC parecen bloqueadas neste sitio (código de erro 401). Se o intento de acceso falla, toca na icona de axuda para ver as FAQ. + Non se puido ler o sitio WordPress nesa URL. Toca na icona de axuda para ver as FAQ. Os servizos XML-RPC están desactivados neste sitio. Menú A túa busca inclúe caracteres non compatibles nos dominios de WordPress.com. Permítense os seguintes caracteres: A–Z, a–z, 0–9. - Estatísticas de hoxe Comproba a túa conexión a Internet e actualiza a páxina. + Estatísticas de hoxe Ocorreu un erro ao actualizar o contido do aviso Editar - Marcar como spam - Mover á papeleira Fallo ao moderar os comentarios + Mover á papeleira + Marcar como spam Rexeitar Axustes da galería de mosaico Navega á pantalla de selección do deseño @@ -762,58 +758,58 @@ Language: gl_ES Podes conectar a túa conta de %s na web de WordPress.com. Cando o teñas feito, volve á aplicación para cambiar os teus axustes sociais. Icona da aplicación Icona de volver - WordPress Logotipo de Automattic + WordPress + WooCommerce Tumblr + Simplenote + Pocket Casts Jetpack Day One - Simplenote - WooCommerce Código fonte - Pocket Casts Política de privacidade Termos do servizo Traballa desde calquera lugar - Valóranos - Twitter - Instagram Traballa con nós - Legal e outros Familia Automattic + Legal e outros + Twitter + Instagram + Valóranos Compartir con amigos Podes editar este bloque usando a versión web do editor. Abrir os axustes de seguridade de Jetpack Nota: Debes permitir o acceso desde WordPress.com para editar este bloque no editor móbil. - ENGADIR MEDIOS - Axustes do enderezo Nota: O deseño pode variar entre temas e tamaños de pantalla + Axustes do enderezo + ENGADIR MEDIOS Estamos tendo problemas neste momento para cargar os datos do teu sitio. Algúns datos non se cargaron + O escritorio non está actualizado. Por favor, comproba a túa conexión e logo pulsa para refrescar. Non se puido actualizar o escritorio. Vídeo non subido! Para subir vídeos de máis de 5 minutos é necesario un plan de pago. - O escritorio non está actualizado. Por favor, comproba a túa conexión e logo pulsa para refrescar. Agradecementos Aviso de privacidade de California - Blog - Tamaño da fonte - Sobre %1$s - O básico - Obter soporte Versión %1$s - Legal e outros Agradecementos + Legal e outros + Sobre %1$s + Blog + O básico Seleccionado: Por defecto Máis opcións de soporte + Obter soporte + Tamaño da fonte Toca dúas veces para seleccionar un tamaño de fonte - %1$s (%2$s) + Toca dúas veces para seleccionar o tamaño de fonte por defecto Contactar co soporte - Ver todos os comentarios + %1$s (%2$s) Seguir a conversa Sé o primeiro en comentar + Ver todos os comentarios Houbo un erro ao obter os datos da entrada - Toca dúas veces para seleccionar o tamaño de fonte por defecto - Axustes para seguir a conversa Houbo un erro ao obter os comentarios + Axustes para seguir a conversa Desde o portapapeis Imaxe destacada Copiar a URL desde o portapapeis, %s @@ -827,124 +823,124 @@ Language: gl_ES Autor Copiar ligazón Engadir un dominio personalizado fai que sexa máis fácil para os teus visitantes encontrar o teu sitio - Sen título Engade o teu dominio + As entradas aparecen na páxina do teu blog en orde cronoloxicamente inverso. É o momento de compartir as túas ideas co mundo! Crear a túa primeira entrada + Sen título Seguintes entradas programadas - As entradas aparecen na páxina do teu blog en orde cronoloxicamente inverso. É o momento de compartir as túas ideas co mundo! Traballa no borrador dunha entrada <span style=\"color:#008000;\">Gratis o primeiro ano </span><span style=\"color:#50575e;\"><s>%s /ano</s></span> Crear unha ligazón Seleccionar o dominio Dominios Fixa + Fixar a entrada na portada Marcar como fixa Deixar de seguir a conversación Activar os avisos da aplicación - Fixar a entrada na portada Estás seguindo esta conversa. Recibirás avisos por correo electrónico cando se publiquen novos comentarios. - Non se puideron activar os avisos da aplicación - Non se puideron desactivar os avisos da aplicación Xestionar as opcións para seguir a conversa, ventá emerxente - Activados os avisos da aplicación + Non se puideron desactivar os avisos da aplicación + Non se puideron activar os avisos da aplicación Desactivados os avisos da aplicación + Activados os avisos da aplicación Cancelada a subscrición a esta conversación Seguindo esta conversa\nActivar os avisos da aplicación? Buscar un dominio - Co teu plan, tes incluído o rexistro de dominio gratis durante un ano Os dominios comprados neste sitio redirixirán aos visitantes a <b>%s</b> - Engadir un dominio + Co teu plan, tes incluído o rexistro de dominio gratis durante un ano Reclama o teu dominio gratuíto + Engadir un dominio <span style=\"color:#d63638;\">Caduca o %s</span> Caduca o %s - %s<span style=\"color:#50575e;\"> /ano</span> <span style=\"color:#B26200;\">%1$s o primeiro ano </span><span style=\"color:#50575e;\"><s>%2$s /ano</s></span> - Nome - Feito - Comentario - Enderezo web - Enderezo de correo electrónico + %s<span style=\"color:#50575e;\"> /ano</span> + Queres descartalos? + Hai cambios sen gardar + O comentario non pode estar baleiro Correo electrónico do usuario non válido Enderezo web non válido - O comentario non pode estar baleiro - Hai cambios sen gardar O nome de usuario non pode estar baleiro - Queres descartalos? + Enderezo de correo electrónico + Enderezo web + Comentario + Nome + Feito Pronto chegarán as vistas previas dos bloques incrustados Resumo semanal Opcións de incrustación Dobre toque para ver as opcións de incrustación. Sitio creado! Completa outra tarefa. - <a href=\"\">A 1 blogueiro</a> gústalle. <a href=\"\">A %1$s blogueiros</a> gústalles. - <a href=\"\">A ti e a 1 blogueiro</a> gústavos. + <a href=\"\">A 1 blogueiro</a> gústalle. <a href=\"\">A ti e a %1$s blogueiros</a> gústavos. + <a href=\"\">A ti e a 1 blogueiro</a> gústavos. <a href=\"\">A ti</a> gústache. Altura da liña Obtén o teu dominio Erro descoñecido ao recuperar o modelo recomendado da aplicación - Dominios - Enlaces rápidos - Non se recibiu ningunha resposta Resposta recibida non válida - Comparte WordPress cun amigo + Non se recibiu ningunha resposta Aplicacións Automattic - Aplicacións para calquera pantalla - Hora do aviso + Comparte WordPress cun amigo + Enlaces rápidos + Dominios Repaso semanal: %s + Hora do aviso Recibirás recordatorios para bloguear <b>todos os días</b> ás <b>%s</b>. %1$s á semana ás %2$s Os controis de formato de texto están dentro da barra de ferramentas situada enriba do teclado mentres editas un bloque de texto - Mover bloques Selecionado: %s Selecciona unha cor de arriba + Navega para seleccionar %s + Mover bloques Como editar a túa entrada Como editar a túa páxina - Navega para seleccionar %s Personalizar bloques Os cambios na imaxe destacada non se verán afectados polos botóns de desfacer/refacer. Aplica o axuste Podes reorganizar os bloques tocando un bloque e logo tocando as frechas arriba e abaixo que aparecen na parte inferior esquerda do bloque para movelo encima ou debaixo doutros bloques. Benvido ao mundo dos bloques Para eliminar un bloque, selecciona o bloque e fai clic nos tres puntos da parte inferior dereita do bloque para ver os axustes. A partir de aí, elixe a opción para eliminar o bloque. - Bloque %s, dispoñible novamente Algúns bloques teñen axustes adicionais. Toca a icona dos axustes na parte inferior dereita do bloque para ver máis opcións. + Bloque %s, dispoñible novamente Edición de texto enriquecido Unha vez que te familiarices cos nomes dos diferentes bloques, podes engadir un bloque escribindo unha barra inclinada seguida do nome do bloque, por exemplo, «/imaxe» ou «/cabeceira. Fai que o teu contido destaque engadindo imaxes, gifs, vídeos e medios incrustados ás túas páxinas. - Medio incrustado Próbao engadindo uns cantos bloques á túa entrada ou páxina! + Medio incrustado Cada bloque ten os seus propios axustes. Para encontralos, toca nun bloque. Os seusS axustes aparecerán na barra de ferramentas da parte inferior da pantalla. Crear deseños Os bloques son pezas de contido que podes insertar, reorganizar e dar estilo sen necesidade de saber programar. Os bloques son unha forma fácil e moderna para que crees bonitos deseños. Os bloques permítenche centrarte na escritura do teu contido, sabendo que todas as ferramentas de formato que necesitas están aí para axudarche a transmitir a túa mensaxe. Organiza o teu contido en columnas, engade botóns de chamada á acción e superpón imaxes con texto. - %1$s de %2$s completado Engade un novo bloque en calquera momento tocando a icona «+» na barra de ferramentas na parte inferior esquerda. - Fallou a moderación dun o máis comentarios + %1$s de %2$s completado Aprende o básico cun recorrido rápido. + Fallou a moderación dun o máis comentarios Crear un sitio - Activar as estatísticas do sitio + Ten o teu sitio activo e funcionando en só uns rápidos pasos Crea a túa web WordPress Non se puideron activar as estatísticas do sitio - Ten o teu sitio activo e funcionando en só uns rápidos pasos + Activar as estatísticas do sitio Activa as estatísticas do sitio para ver información detallada sobre o tráfico, os «Gústame», os comentarios e os subscritores. - Que é un bloque? Buscas as estatísticas? + Que é un bloque? Estamos traballando duro para engadir compatibilidade para vistas previas %s. Mentres tanto, podes previsualizar o contido incrustado na entrada. Estamos traballando duro para engadir compatibilidade para vistas previas %s. Mentres tanto, podes previsualizar o contido incrustado na páxina. - Non se encontraron bloques Non se puido incrustar o medio Proba outro termo de busca + Non se encontraron bloques Aínda non están dispoñibles as vistas previas de %s Pronto chegarán as vistas previas do bloque incrustado %s Toca dúas veces para previsualizar a entrada. Toca dúas veces para previsualizar a páxina. Mostrado na pestana do navegador do teu visitante e noutros sitios en liña. Móstrame o camiño + Queres unha pequena axuda para xestionar este sitio coa aplicación? Crear un novo sitio - Elixe un sitio para abrir Podes cambiar os sitios en calquera momento. - Queres unha pequena axuda para xestionar este sitio coa aplicación? + Elixe un sitio para abrir Sentímolo, neste momento Jetpack Scan non é compatible coas instalacións multisitio de WordPress. Os multisitios de WordPress non son compatibles URL non válida. Por favor, introduce unha URL válida. @@ -952,86 +948,86 @@ Language: gl_ES Lenda incrustada. Baleira visita a nosa páxina de documentación Jetpack Backup para instalacións multisitio proporciona copias de seguridade descargables, non restauracións cun só clic. Para máis información, %1$s. + Publicar regularmente pode axudar a que os teus lectores permanezan implicados, e a atraer novos visitantes ao teu sitio. Consello Podes actualizar isto en calquera momento Selecciona os días nos que queres bloguear Podes actualizar isto en calquera momento desde O meu sitio > Axustes > Recordatorios de blogueo. - Publicar regularmente pode axudar a que os teus lectores permanezan implicados, e a atraer novos visitantes ao teu sitio. - Todo configurado! - Recordatorios eliminados! Non tes configurado ningún recordatorio. Recibirás recordatorios para bloguear %1$s á semana o %2$s ás %3$s. + Recordatorios eliminados! + Todo configurado! Actualizar Nada configurado %s á semana Configurar recordatorios Configura recordatorios de blogueo os días que queiras publicar. A túa entrada estase publicando… mentres tanto podes configurar recordatorios de blogueo os días que quieras publicar. - É hora de bloguear en %s Configura os teus recordatorios de blogueo Este é o teu recordatorio para crear algo hoxe + É hora de bloguear en %s WordPress para iOS aínda non é compatible con editar bloques reutilizables WordPress para Android aínda non é compatible con editar bloques reutilizables Alternativamente, podes separar e editar estes bloques por separado tocando en «Separar padróns». Feito Avísame <a href=\"%1$s\">Introduce as credenciais do teu servidor</a> para activar as restauracións do sitio cun clic das copias de seguridade. - Crear unha categoría Establecer como imaxe destacada Eliminar como imaxe destacada + Crear unha categoría Soporte de WordPress para Android Xestiona as categorías do teu sitio Categorías - O contido da páxina das túas últimas entradas xérase automaticamente e non se pode editar. Recordatorios + O contido da páxina das túas últimas entradas xérase automaticamente e non se pode editar. Axustes do borde - Ver o almacenamento Non amosar de novo + Ver o almacenamento Tenemos que gardar o teu contido no teu dispositivo antes de que poida ser publicado. Revisa os teus axustes de almacenamento e elimina arquivos para gañar espazo. Insuficiente almacenamento no dispositivo Posición do eixo Y Posición do eixo X Teclea unha URL + Resultados do insertador de corte %s ten unha URL configurada %s non ten unha URL configurada - Resultados do insertador de corte - Bloque %s %s convertido a bloques normais + Bloque %s + Opacidade Opcións de medios URL non válida. Arquivo de audio non encontrado. - Opacidade Insertar entrada cruzada Arrastra para axustar o punto focal - Toca dúas veces para abrir a folla de acción para engadir imaxe ou vídeo Toca dúas veces para abrir a folla inferior para engadir imaxe ou vídeo - Entrada cruzada - Axustes de columnas + Toca dúas veces para abrir a folla de acción para engadir imaxe ou vídeo A unidade actual é %s + Entrada cruzada %s convertido a bloque normal - Engadir texto do enlace + Axustes de columnas Engadir enlace a %s + Engadir texto do enlace Engadir unha imaxe ou vídeo - Non se puido encontrar o arquivo de medios na ruta A ruta especificada é un directorio e non un arquivo de medios - O medio estaba baleiro - O tipo de arquivo non está permitido + Non se puido encontrar o arquivo de medios na ruta Ruta de arquivo de medios baleira inesperada - <a href=\"%1$s\">Introduce as credenciais do teu servidor</a> para corrixir a ameaza. + O tipo de arquivo non está permitido + O medio estaba baleiro <a href=\"%1$s\">Introduce as credenciais do teu servidor</a> para corrixir as ameazas. - Ver as instrucións - Probar con outra conta + <a href=\"%1$s\">Introduce as credenciais do teu servidor</a> para corrixir a ameaza. Toca dúas veces para engadir un enlace. + Probar con outra conta + Ver as instrucións Se xa tes un sitio, terás que instalar o plugin gratuíto de Jetpack e conectalo á túa conta de WordPress. A túa foto de perfil Se queres usar esta aplicación para %1$s, deberás ter o plugin de Jetpack configurado e conectado a unha conta de WordPress.com. - Axustes de anchura Mover a imaxe cara diante Mover a imaxe cara atrás - Axustes de columna + Axustes de anchura «rel» da ligazón + Axustes de columna Sen descrición - Sitio (Sen título) + Sitio Información de folla inferior do perfil de usuario Lista de Gústame %s Dous @@ -1045,17 +1041,17 @@ Language: gl_ES Reintentar GIF Un - Vista previa non dispoñible Engade o título + Vista previa non dispoñible Cargando - ligazón %s Cor do texto + ligazón %s Recheo - Destacado Catro + Destacado Engadir imaxe - Crear uhna incrustación URL personalizada + Crear uhna incrustación Columna %d Máis Describe brevemente o enlace para axudar aos usuarios de lectores de pantalla @@ -1065,562 +1061,563 @@ Language: gl_ES Transformar %s a Transformar bloque… Fallo ao insertar os medios. - %d gústame Fallo ao insertar o arquivo de audio. - Erro ao cargar os datos de gústame. %s - %1$s transformado a %2$s Describe o propósito da imaxe. Déixao baleiro se a imaxe é puramente decorativa. + %1$s transformado a %2$s + Erro ao cargar os datos de gústame. %s + %d gústame 1 gústame Suxerencia: - Bloques de busca Usar botón de icona Campo de introdución de busca. - Etiqueta do bloque de busca. O texto actual é Botón de busca. O texto actual do botón é - Dentro - Ocultar o encabezado de busca + Bloques de busca + Etiqueta do bloque de busca. O texto actual é Exterior Non se estableceu ningún marcador de posición personalizado + Dentro + Ocultar o encabezado de busca + Dobre toque para editar o texto do marcador de posición Dobre toque para editar o texto da etiqueta Dobre toque para editar o texto do botón - Dobre toque para editar o texto do marcador de posición dobre toque para cambiar a unidade - Sen responder + O texto de marcador de posición actual é Baleirar a busca Cancelar a busca - Non hai ningunha rede dispoñible. - Non hai ningún comentario sen responder + Posición do botón %1$s. %2$s é %3$s %4$s. Ocorreu un erro ao obter os datos dos gústame Ocorreu un erro ao obter os gústame. - Posición do botón - O texto de marcador de posición actual é - Axustes de busca + Non hai ningunha rede dispoñible. + Non hai ningún comentario sen responder + Sen responder ENGALIR LIGAZÓN - Comentarios non permitidos + Axustes de busca Direccións IP permitidas sempre + Comentarios non permitidos Engadir o texto do botón Seguir temas + Unha nova forma de crear e publicar contidos atraíntes no teu sitio. Descartar Descargar - Unha nova forma de crear e publicar contidos atraíntes no teu sitio. Ameazas corrixidas correctamente. Por favor, confirma que queres corrixir todas as %s ameazas activas. A exploración encontrou %1$s ameazas potenciais con %2$s. Por favor, revísaas a continuación e leva a cabo algunha acción ou toca o botón de corrixir todo. Estamos %3$s se nos necesitas. Traballamos duro para corrixir estas ameazas en segundo plano. Mentres tanto podes seguir usando o teu sitio como sempre, podes volver a comprobar o progreso en calquera momento. Editar o punto focal - example.com - Escribe un nome para o teu sitio Toque dobre para abrir a folla do fondo para editar, substituír ou baleirar a imaxe Toque dobre para abrir a folla de acción para editar, substituír ou baleirar a imaxe + example.com + Escribe un nome para o teu sitio <b>Completáronse todas as tarefas</b><br/>Chegaches a máis xente. Bo traballo! <b>Completáronse todas as tarefas</b><br/>Personalizaches o teu sitio. Ben feito! Non querías crear unha nova conta? Volve atrás e volve a introducir o teu enderezo de correo electrónico. Unha vez desactivado o enlace de invitación, ninguén poderá usalo para unirse ao teu equipo. Seguro que desexas continuar? Desactivar enlace de invitación - Non se recibiu ningunha resposta Resposta recibida non válida + Non se recibiu ningunha resposta + Ocorreu un erro ao recuperar datos para o perfil %1$s Houbo un erro ao obter os perfís Erro descoñecido ao obter os datos dos enlaces de invitación - Ocorreu un erro ao recuperar datos para o perfil %1$s Utiliza este enlace para embarcar aos membros do teu equipo sen ter que invitalos un a un. Calquera que visite estas URL poderá rexistrarse na túa organización, anque recibira o enlace doutra persoa, así que asegúrate de que o compartes con xente de confianza. - Enlace de invitación Caduca %1$s + Desactivar enlace de invitación Compartir enlace de invitación Xerar novo enlace de invitación - Desactivar enlace de invitación Refrescar o estado do enlace + Enlace de invitación Encontrouse unha ameaza Encontráronse ameazas - <b>Exploración finalizada</b><br>Non se encontraron ameazas potenciais <b>Exploración finalizada</b><br>Encontradas %s ameazas potenciais <b>Exploración finalizada</b><br>Encontrada unha ameaza potencial - Desactivar + <b>Exploración finalizada</b><br>Non se encontraron ameazas potenciais Corrixindo a ameaza + Desactivar Revisa as túas páxinas e fai cambios, ou engade ou elimina páxinas. Visita o teu sitio Descobre e segue sitios que te inspiren. + Compartir socialmente Comparte automaticamente as novas entradas nos teus medios sociais. Dálle un nome ao teu sitio que reflexe a súa personalidade e temática. - Compartir socialmente Revisa as túas estatísticas Trataremos de crear un arquivo de copia de seguridade descargable. Non puidemos encontrar o estado para dicir canto tardará a túa copia de seguridade descargable. - Icona de reloxo + Vaia, non puidemos encontrar o estado da túa copia de seguridade descargable Icona de marca de comprobación + Icona de reloxo Avisámoste cando teñamos rematado. - Vaia, non puidemos encontrar o estado da túa copia de seguridade descargable - Non puidemos restaurar o teu sitio Volveremos a intentar restaurar o teu sitio. - Vaia, non puidemos encontrar o estado da túa restauración Non puidemos encontrar o estado para decir canto tardará a túa restauración. - (SQL) + Vaia, non puidemos encontrar o estado da túa restauración + Non puidemos restaurar o teu sitio Confirmar + Estás seguro de querer reverter o teu sitio ao %1$s ás %2$s?\n Todo o que cambiaras desde entón perderase. Non puidemos crear a túa copia de seguridade + (SQL) (exclúe temas, plugins e subidas) - Estás seguro de querer reverter o teu sitio ao %1$s ás %2$s?\n Todo o que cambiaras desde entón perderase. - Subindo… - Raíz de WordPress Directorio wp-content + Raíz de WordPress Elementos incluídos nesta descarga - ABERTO - Icona de cadeado + Subindo… Substituír arquivo Substituír audio Problema ao abrir o audio - Toca dúas veces para seleccionar un arquivo de audio + ABERTO Ningunha aplicación pode xestionar esta solicitude. + Icona de cadeado Fallo ao insertar o arquivo de audio. Por favor, toca para ver as opcións. + Toca dúas veces para seleccionar un arquivo de audio + Toca dúas veces para escoitar o arquivo de audio Elixir audio Reprodutor de audio - Usar este audio + arquivo de audio Lenda do audio. %s Lenda do audio. Baleira - Elixe un audio do dispositivo - Toca dúas veces para escoitar o arquivo de audio + Engadir audio Accede ou rexístrate con WordPress.com + Usar este audio + Elixe un audio do dispositivo Opcional: introduce unha mensaxe personalizada para enviar coa túa invitación. - arquivo de audio - Engadir audio + Aprende máis sobre os perfís Corrixido Encontrado aquí para axudar - Aprende máis sobre os perfís A exploración encontrou unha ameaza potencial con %1$s. Por favor, revísaas a continuación e leva a cabo algunha acción ou toca o botón de corrixir todo. Estamos %2$s se nos necesitas. Para revisar o teu sitio de novo, executa unha exploración manual ou espera a que Jetpack explore o teu sitio máis tarde hoxe mesmo. Benvido á exploración de Jetpack! Estamos botándolle un vistazo á túa web para deixalo todo a punto para a primeira análise completa. Informarémoste se encontramos algún problema que lle poida afectar e despois comezará a túa primeira análise. Benvido á ferramenta de exploración de Jetpack, estamos botándolle un primeiro vistazo á túa web nestes momentos, mostrarémosche os resultados enseguida. - Enviarémosche un aviso se se encontra unha ameaza. Mentres tanto, non dubides en seguir usando o teu sitio con normalidade, podes comprobar o progreso en calquera momento. Traballamos duro para corrixir estas ameazas en segundo plano. Mentres tanto podes seguir usando o teu sitio coma sempre, podes volver a comprobar o progreso en calquera momento. + Enviarémosche un aviso se se encontra unha ameaza. Mentres tanto, non dubides en seguir usando o teu sitio con normalidade, podes comprobar o progreso en calquera momento. Corrixindo ameazas Jetpack Scan non puido realizar unha análise do teu sitio. Comproba se o teu sitio está caído. Se non, volve a intentalo. Se o teu sitio está caído ou se Jetpack Scan segue tendo problemas, ponte en contacto co noso equipo de soporte. - Facendo copia de seguridade do sitio Algo saíu mal - Creando unha copia de seguridade descargable + Facendo copia de seguridade do sitio Facendo copia de seguridade do sitio desde %1$s %2$s + Creando unha copia de seguridade descargable A copia de seguridade do teu sitio realizouse correctamente - Elixir audio A copia de seguridade do teu sitio realizouse correctamente\nFeita a copia de seguridade desde %1$s %2$s A copia de seguridade do teu sitio estase realizando\nFacendo a copia de seguridade desde %1$s %2$s + Elixir audio + Hai outra restauración en curso. Icona de erro Botón Listo - Hai outra restauración en curso. - Visitar o sitio - Botón Listo - Icona de restaurar Non se puido restaurar Botón Visitar sitio - Restaurouse o teu sitio + Botón Listo + Icona de restaurar + Visitar o sitio Todos os elementos seleccionados restauráronse á versión do %1$s %2$s. + Restaurouse o teu sitio Non fai falta que esperes. Enviarémosche un aviso cando se complete a restauración. Icona de restaurar sitio + Estamos restaurando a versión do teu sitio do %1$s %2$s. Estamos restaurando o sitio Botón de Confirmar a restauración do sitio - Estamos restaurando a versión do teu sitio do %1$s %2$s. + Imaxe dun círculo vermello cun signo de exclamación Advertencia Botón Restaurar sitio - Imaxe dun círculo vermello cun signo de exclamación - Listo - Restaurar - Botón Listo Icona de restaurar Restaurar sitio + %1$s %2$s é o punto seleccionado para a restauración. Restaurar sitio - Nube con icona X Elixe os elementos que queres restaurar: - %1$s %2$s é o punto seleccionado para a restauración. + Restaurar + Nube con icona X + Botón Listo + Listo + A descarga fallou Tableta Dispositivos móbiles - A descarga fallou - Revisar as páxinas do sitio - Cambia, engade ou elimina páxinas no teu sitio. Selecciona %1$s Páxinas %2$s para ver a túa lista de páxinas. + Cambia, engade ou elimina páxinas no teu sitio. + Revisar as páxinas do sitio Selecciona %1$s Páxina de inicio %2$s para editar a túa páxina de inicio. - Marcar como lida Marcar como non lida - Marcar entrada como lida - Marcar entrada como non lida + Marcar como lida + Non se puideron subir los elementos multimedia.\n%1$s Espazo de almacenamento do sitio insuficiente Non se pode alternar o estado visto desta entrada - Non se puideron subir los elementos multimedia.\n%1$s + Marcar entrada como non lida + Marcar entrada como lida + Produciuse un erro ao comprobar o estado da reparación. Ponte en contacto co servizo de soporte. A ameaza correxiuse correctamente. Produciuse un erro ao corrixir as ameazas. Ponte en contacto co servizo de soporte. Por favor, confirma que queres corrixir unha ameaza activa. - Produciuse un erro ao comprobar o estado da reparación. Ponte en contacto co servizo de soporte. Corrixir todas as ameazas - Ignorouse a ameaza. Non se puido ignorar a ameaza. Ponte en contacto co servizo de soporte. + Ignorouse a ameaza. No deberías ignorar un problema de seguridade a menos que esteas absolutamente seguro de que non é dañino. Se elixes ignorar esta ameaza, seguirá no teu sitio <b>%s</b>. Non se puido corrixir a ameaza. Ponte en contacto co servizo de soporte. - Todos - Corrixido - Ignorouse - Historia - Historial de exploracións - Corrixindo a ameaza Ameaza ignorada + Ameaza corrixida en %s + Corrixindo a ameaza + Ignorouse Non se encontrou ningún elemento + Corrixido + Todos Analizando arquivos Preparando escaneado - Ameaza corrixida en %s - Non se encontraron copias de seguridade coincidentes + Historia + Historial de exploracións Proba a axustar o rango de datas + Non se encontraron copias de seguridade coincidentes A túa primeira copia de seguridade estará dispoñible aquí en 24 horas e recibirás unha notificación unha vez que se complete A túa primeira copia de seguridade pronto estará lista Ocorreu un problema ao xestionar a petición. Por favor, inténtao de novo máis tarde. Mover ao final Cambiar a posición do bloque - icona Subir - Botón de compartir enlace + icona Tamén che enviamos un enlace ao teu arquivo. - Descargar - Compartir enlace + Botón de compartir enlace Botón de descarga Icona de copia de seguridade descargable lista - A túa copia de seguridade xa está dispoñible para descargala + Compartir enlace + Descargar Creamos unha copia de seguridade do teu sitio desde %1$s %2$s. + A túa copia de seguridade xa está dispoñible para descargala A túa copia de seguridade + Non fai falta que esperes. Avisarémoste cando estea lista a copia de seguridade Icona de copia de seguridade descargable en curso - Estase creando unha copia de seguridade descargable do teu sitio Estamos creando unha copia de seguridade descargable do teu sitio desde %1$s %2$s. - Non fai falta que esperes. Avisarémoste cando estea lista a copia de seguridade + Estase creando unha copia de seguridade descargable do teu sitio Descargar copia de seguridade - Botón Crear copia de seguridade descargable Hai outra descarga en curso. - %1$s %2$s é o punto seleccionado para crear unha copia de seguridade descargable. Ocorreu un problema ao xestionar a petición. Por favor, inténtao de novo máis tarde. + Botón Crear copia de seguridade descargable + %1$s %2$s é o punto seleccionado para crear unha copia de seguridade descargable. %1$s · %2$s · - %1$s · %1$s · %2$s - usuario + %1$s · entrada cruzada - Corrixir ameaza - Ignorar ameaza + usuario Non coincide con %s. - Consegue un orzamento gratuíto - Non hai suxerencias %s dispoñibles. - Jetpack Scan solucionará a ameaza. Ocorreu un problema ao cargar as suxerencias. + Non hai suxerencias %s dispoñibles. Escribe algo para filtrar a lista de suxerencias. - Jetpack Scan actualizarase a unha versión máis recente (%s). + Consegue un orzamento gratuíto + Ignorar ameaza + Corrixir ameaza + Jetpack Scan solucionará a ameaza. Jetpack Scan editará o arquivo ou o directorio afectados. + Jetpack Scan actualizarase a unha versión máis recente (%s). Jetpack Scan borrará o arquivo ou o directorio afectados. Jetpack Scan reemplazará o arquivo ou o directorio afectados. Jetpack Scan non pode solucionar automaticamente esta ameaza.\n Suxerímosche que soluciones esta ameaza manualmente: asegúrate de que WordPress, o teu tema e todos os plugins están actualizados e elimina o código, tema ou plugin que estea causando problemas no teu sitio web. \n \n\n Se necesitas máis axuda para resolver esta ameaza, recomendámosche <b>Codeable</b>, unha plataforma de profesionais de confianza, altamente cualificados, expertos en WordPress.\n Fixeron unha selección de expertos en seguridade para axudarnos con estes proxectos. Os prezos oscilan entre 70–120 USD/hora e podes obter un presuposto gratuíto sen compromiso. - Como imos a reparalo? Solucionando a ameaza + Como o solucionou Jetpack? + Como imos a reparalo? Ameaza detectada no arquivo: Información técnica Cal foi o problema? - Como o solucionou Jetpack? Detalles da ameaza - Ameaza encontrada %s - Ameazas de base de datos %s - %s: patrón de código malicioso - Varias vulnerabilidades Encontrouse unha vulnerabilidade nun tema Encontrouse unha vulnerabilidade nun plugin + Ameaza encontrada %s Encontrouse unha vulnerabilidade en WordPress + Varias vulnerabilidades Tema vulnerable: %1$s (versión %2$s) Plugin vulnerable: %1$s (versión %2$s) - Corrixir todo - este sitio + %s: patrón de código malicioso + Ameazas de base de datos %s + %s: arquivo principal infectado Encontrouse unha ameaza - fai %s hora(s) - fai %s minuto(s) + Corrixir todo hai uns segundos - %s: arquivo principal infectado + fai %s minuto(s) + fai %s hora(s) + este sitio Executouse a última exploración de Jetpack %1$s e non se encontrou ningún risco. %2$s - Copias de seguridade - Analizar agora - Analizar de novo - Icona de estado da análise Pode que o teu sitio web estea desprotexido Non te preocupes + Analizar de novo + Analizar agora + Icona de estado da análise + Copias de seguridade Filtro de tipo de actividade (%s tipos seleccionados) + %1$s (mostrando %2$s elementos) Filtro de tipo de actividade + Non se rexistraron actividades no rango de datas seleccionado. Non hai actividades dispoñibles Revisa a túa conexión a Internet e inténtao de novo. - Non se rexistraron actividades no rango de datas seleccionado. - %1$s (mostrando %2$s elementos) Sen conexión - Filtro de rango de datas Tipo de actividade (%s) - Non se encontraron eventos coincidentes + Filtro de rango de datas Intenta axustar os filtros de rango de data ou de tipo de actividade + Non se encontraron eventos coincidentes + Base de datos do sitio + (inclúe wp-config.php e calquera arquivo que non sexa de WordPress) Subidas de medios - Temas de WordPress Plugins de WordPress + Temas de WordPress Crea unha icona de copia de seguridade descargable - Base de datos do sitio - (inclúe wp-config.php e calquera arquivo que non sexa de WordPress) + Crear un arquivo descargable + Crear unha copia de seguridade descargable + Descargar copia de seguridade + Descarga da copia de seguridade + Erro + Elixir arquivo Descargar copia de seguridade Restaurar até este punto Tipo de actividade - Erro - Elixir arquivo - Descargar copia de seguridade - Descarga da copia de seguridade - Crear un arquivo descargable - Crear unha copia de seguridade descargable Rango de datas Filtrar por tipo de actividade - Duplicar - Conflicto de sincronización da entrada - Editar a entrada primeiro Copiar a versión desta aplicación + Editar a entrada primeiro A entrada que estás tratando de copiar ten dúas versións que están en conflito ou fixeches cambios recentemente, pero non os gardaches.\nEdita a entrada primeiro para resolver calquera conflito ou procede a copiar a versión desta aplicación. + Conflicto de sincronización da entrada + Duplicar A historia está sendo gardada, por favor, espera… Nome do arquivo - Editar o arquivo - Copiar a URL do arquivo Axustes do arquivo do bloque Erro ao subir os arquivos.\nPor favor, toca para ver as opcións. Erro ao gardar os medios.\nPor favor, toca para ver as opcións. - Jetpack + Editar o arquivo + Copiar a URL do arquivo Elixe un dominio - Erro ao recuperar o estado da subscrición para a entrada - Non se puido crear a subscrición aos comentarios desta entrada - Seguir a conversa por correo electrónico + Jetpack Seguindo a conversa por correo electrónico + Seguir a conversa por correo electrónico Non se puido anular a subscrición aos comentarios desta entrada + Non se puido crear a subscrición aos comentarios desta entrada + Erro ao recuperar o estado da subscrición para a entrada + Resposta recibida non válida + Non se recibiu ningunha resposta Baleirar Aplicar - Non se recibiu ningunha resposta - Resposta recibida non válida Unha o máis diapositivas non se engadiron á túa historia porque neste momento as historias non son compatibles con arquivos GIF. Por favor, elixe unha imaxe estática ou un vídeo de fondo no seu lugar. - Non se pode editar a historia - Non se pode editar a historia Os arquivos GIF non son compatibles Non puidemos encontrar no sitio os medios para esta historia. + Non se pode editar a historia Non se puido subir medios a esta historia. Comproba a túa conexión a Internet e inténtao de novo dentro dun momento. + Non se pode editar a historia Esta historia editouse nun dispositivo diferente e a posibilidade de editar certos obxectos pode estar limitada. Edición limitada da historia Elimináronse os medios. Intenta volver a crear a túa historia. - Feito - Seguinte + Fondo Texto - Borrar Descartar - Fondo + Calquera cambio realizado non se gardará. Descartar cambios? + Feito + Seguinte + Borrar + Ocorreu un erro ao seleccionar o tema. + Por favor, revisa a túa conexión a Internet e inténtao de novo. Toca en reintentar cando volvas a estar conectado. - Calquera cambio realizado non se gardará. Os deseños non están dispoñibles sen conexión - Por favor, revisa a túa conexión a Internet e inténtao de novo. - Ocorreu un erro ao seleccionar o tema. - Explorar - Benvido! + Continuar coas credenciais da tenda + Encontra o teu correo electrónico conectado Seguir temas + Proba a seguir máis temas para ampliar a busca Non hai entradas recentes - Encontra o teu correo electrónico conectado - Continuar coas credenciais da tenda - Proba a seguir máis temas para ampliar a busca - A <b>Madison Ruíz</b> gustoullea túa entrada + Benvido! + Explorar <b>Xan Vilaboi</b> respondeu na túa entrada Hoxe recibiches <b>50 gústame</b> no teu sitio - Elixir - Pechouse o menú de bloques desprazable. + A <b>Madison Ruíz</b> gustoullea túa entrada Abriuse o menú de bloques desprazable. Selecciona un bloque. - Non establecido - Categorías - Categorías - Engadir unha categoría - Engadir unha nova categoría + Pechouse o menú de bloques desprazable. + Elixir + Toca «Reintentar» cando volvas a estar en liña ou crea unha páxina en branco usando o seguinte botón. Os deseños non están dispoñibles sen conexión - Os deseños non están dispoñibles debido a un erro Toca «Reintentar» ou crea unha páxina en branco usando o seguinte botón. - Toca «Reintentar» cando volvas a estar en liña ou crea unha páxina en branco usando o seguinte botón. - Arte - Música - Cociña + Os deseños non están dispoñibles debido a un erro + Engadir unha categoría + Engadir unha nova categoría + Categorías + Non establecido + Categorías + Museos en Londres + Os mellores fanáticos do mundo + Os meus dez mellores cafés Política + Música + Xardinería Fútbol + Cociña + Arte + Rock n\' roll semanal Noticias web - Xardinería Pamela Nguyen - Os meus dez mellores cafés - Museos en Londres - Rock n\' roll semanal - Os mellores fanáticos do mundo Estou moi inspirado polo traballo do fotógrafo Cameron Karsten. Probarei estas técnicas no meu próximo Inspírate + Segue o teus sitios favoritos e descobre novos blogs. + Observa como crece a túa audiencia con analíticas avanzadas. Mira os comentarios e avisos en tempo real. Co potente editor podes publicar sobre a marcha. - Observa como crece a túa audiencia con analíticas avanzadas. - Segue o teus sitios favoritos e descobre novos blogs. Benvido ao maquetador web máis popular do mundo. - Sitios a seguir A carga do medio fallou + Sitios a seguir Estamos traballando duro para engadir máis bloques con cada versión. «%s» non é totalmente compatible Botón de axuda + Editar usando o editor web Elixir as imaxes Crear unha entrada de historia - Editar usando o editor web Son publicados coma unha nova entrada de blog no teu sitio para que a túa audiencia nunca se perda nada. As entradas de historias non desaparecen Combina fotos, vídeos e texto para crear entradas de historias atractivas e accesibles que lles encantarán aos teus visitantes. - Páxina creada - Páxina en branco creada + Agora as historias son para todos Título da historia de exemplo - Presentación das entradas de historias Como crear unha entrada de historias - Agora as historias son para todos + Presentación das entradas de historias + Páxina en branco creada + Páxina creada Inserción do medio fallida. Fallou a inserción do medio: %s Elixe desde a biblioteca de medios de WordPress Volver - por Primeiros pasos Segue temáticas para descubrir novos blogs + por + Este referido non pode ser marcado como spam + Desmarcar como spam Marcar como spam Abrir a web - Desmarcar como spam - Subindo medios Subindo medios GIF Subindo medios de inventarios - Este referido non pode ser marcado como spam + Subindo medios Busca ou escribe a URL Engadir este enlace de teléfono Engadir este enlace Engadir este enlace de correo electrónico Non hai conexión a Internet.\nNon están dispoñibles as suxestións. - %s Grosa - Forte - Casual Moderno - Clásico Alegre - %s seleccionado + Forte + Clásico + Casual Tes que conceder permisos de gravación de audio á aplicación para gravar vídeo - Micrófono - Navegar por elementos + %s + %s seleccionado Obter un enlace de acceso por correo electrónico - Non se pode amosar este comentario Vaia, non encontramos unha conta de WordPress.com conectada a este enderezo de correo electrónico. + Micrófono + Non se pode amosar este comentario + Navegar por elementos Informar desta entrada - %1$s elementos máis - A túa acción non está permitida - Ocorreu un erro interno no servidor Benvido ao lector. Descobre millóns de blogs ao teu alcance. + Ocorreu un erro interno no servidor + A túa acción non está permitida + %1$s elementos máis Seleccionar un deseño Nota: o deseño da columna pode variar entre temas e tamaños de pantalla - Ocultar + Crear unha entrada ou historia Crear unha páxina Crear unha entrada Pode que che guste - Crear unha entrada ou historia - Ver o almacenamento + Ocultar + Lenda do vídeo. Baleira + Actualiza o título. + Pegar o bloque despois Título da páxina. %s Título da páxina. Baleiro - Pegar o bloque despois - Erro ao gardar a imaxe - Actualiza o título. - Lenda do vídeo. Baleira + Ocorreu un erro ao reproducir o teu vídeo + Este dispositivo non é compatible coa API de Camera2 Non se puido gardar o vídeo - Non se puido encontrar a diapositiva da historia + Erro ao gardar a imaxe Operación en progreso, inténtao de novo - Este dispositivo non é compatible coa API de Camera2 - Ocorreu un erro ao reproducir o teu vídeo - Xestionar - Non se puido gardar 1 diapositiva - 1 diapositiva necesita unha acción - Non se puideron gardar %1$d diapositivas - %1$d diapositivas necesitan unha acción + Non se puido encontrar a diapositiva da historia + Ver o almacenamento + Tenemos que gardar a historia no teu dispositivo antes de que poida ser publicada. Revisa os teus axustes de almacenamento e elimina arquivos para gañar espazo. Almacenamento insuficiente no dispositivo Intenta volver a gardar ou borrar as diapositivas e, despois, intenta volver a publicar a túa historia. - Tenemos que gardar a historia no teu dispositivo antes de que poida ser publicada. Revisa os teus axustes de almacenamento e elimina arquivos para gañar espazo. + Non se puideron gardar %1$d diapositivas + Non se puido gardar 1 diapositiva + Xestionar + %1$d diapositivas necesitan unha acción + 1 diapositiva necesita unha acción + Non se puido subir «%1$s» + Non se puido subir «%1$s» Publicado «%1$s» Subindo «%1$s»… - Non se puido subir «%1$s» - Non se puido subir «%1$s» - varias historias + Quedan %1$d diapositivas Queda 1 diapositiva + varias historias Gardando «%1$s»… - Quedan %1$d diapositivas - Borrar + Sen título Descartar + A entrada da túa historia non se gardará como borrador. + Descartar a entrada da historia? + Borrar + Esta diapositiva aínda non se gardou. Se borras esta diapositiva, perderás calquera edición que fixeras. + Esta diapositiva será eliminada da túa historia. + Borrar a diapositiva da historia? + Cambiar a cor do texto + Cambiar a aliñación do texto con erro - Sen título seleccionado sen seleccionar - Cambiar a cor do texto - Borrar a diapositiva da historia? - Descartar a entrada da historia? - Cambiar a aliñación do texto - Esta diapositiva será eliminada da túa historia. - A entrada da túa historia non se gardará como borrador. - Esta diapositiva aínda non se gardou. Se borras esta diapositiva, perderás calquera edición que fixeras. - Xirar - Texto - Gardado - Flash - Son + Diapositiva + Reintentar Gardado + Pechar + Compartir con COMPARTIR - Reintentar - Diapositiva - Flash + Gardado en fotos Reintentar + Gardado Gardando - Capturar - Vista previa + Flash + Xirar + Son + Texto Pegatinas - Compartir con + Flash Xirar a cámara + Capturar + Vista previa Crear unha páxina - Gardado en fotos - Elixir un deseño Crear unha páxina en branco - Crear unha entrada ou historia + Empeza elixindo entre unha ampla variedade de deseños de páxina prefabricados. Ou simplemente empeza cunha páxina en branco. + Elixir un deseño Pon un título á túa historia + Crear unha entrada ou historia Crear unha entrada, páxina ou historia Toca %1$s Crear. %2$s Despois selecciona <b>Entrada do blog</b> - Empeza elixindo entre unha ampla variedade de deseños de páxina prefabricados. Ou simplemente empeza cunha páxina en branco. - Pechar - Para editares as iconas nos sitios auto-aloxados precisas o plugin Jetpack. - Entrada da historia Elixir o dispositivo - Cota de almacenamento superada + Entrada da historia + Para editares as iconas nos sitios auto-aloxados precisas o plugin Jetpack. Non se puido encontrar o salto de páxina enlazado Non se pode subir o arquivo.\nSuperouse a cota de almacenamento. + Cota de almacenamento superada Engadir un arquivo Substituír o vídeo Substituír a imaxe ou vídeo + Converter en ligazón Elixir un vídeo + Elixir unha imaxe ou vídeo Elixir unha imaxe Bloque eliminado - Confirmación do rexistro - Elixir unha imaxe ou vídeo Introduce o enderezo do teu sitio existente + Confirmación do rexistro Se continúas con Google e aínda non tes unha conta de WordPress.com, crearás unha conta e aceptas os nosos %1$stermos do servizo%2$s. - Converter en ligazón Se continúas, aceptas os nosos %1$stermos do servizo%2$s. Usaremos este enderezo de correo electrónico para crear a túa nova conta de WordPress.com. Enviámosche por correo electrónico un enlace de rexistro para crear a túa nova conta de WordPress.com. Comproba o teu correo electrónico neste dispositivo e toca o enlace no correo electrónico que recibiches de WordPress.com. Introduce a información da túa conta para %1$s. ou - Feito Continuar con Google Encontra o enderezo do teu sitio + Feito No ves o correo electrónico? Comproba a túa carpeta de spam ou correo non desexado. - Enviarémosche por correo electrónico un enlace que che fará acceder automaticamente, sen necesidade de contrasinal. Comproba o teu correo electrónico neste dispositivo e toca o enlace no correo electrónico que recibiches de WordPress.com. - Primeiros pasos + Enviarémosche por correo electrónico un enlace que che fará acceder automaticamente, sen necesidade de contrasinal. Comprobar o correo electrónico + Primeiros pasos + Introduce o teu enderezo de correo electrónico para acceder ou crear unha conta de WordPress.com. + Ou escribe a túa contrasinal Crear unha conta Enviar o enlace por correo electrónico Restablecer o teu contrasinal - Ou escribe a túa contrasinal - Introduce o teu enderezo de correo electrónico para acceder ou crear unha conta de WordPress.com. Ocorreu un problema ao xestionar a petición. Por favor, inténtao de novo máis tarde. - Toca <b>%1$s</b> para configurar un novo título Comproba o título do teu sitio + Toca <b>%1$s</b> para configurar un novo título Ao enviar esta entrada á papeleira tamén se descartarán os cambios locais. Estás seguro de que queres seguir? Opciones do bloque %s + Eliminar o bloque Duplicar bloque Copiar bloque Bloque copiado @@ -1629,27 +1626,27 @@ Language: gl_ES Bloque cortado Bloque copiado O título do sitio só pode ser cambiado por un usuario co perfil de administrador. - Eliminar o bloque + O título do sitio móstrase na barra de título dun navegador web e na cabeceira da maioría dos temas. Tema - Cambios sen gardar Non se puido actualizar o título do sitio. Comproba a túa conexión de rede e inténtao de novo. - O título do sitio móstrase na barra de título dun navegador web e na cabeceira da maioría dos temas. + Cambios sen gardar Abrir o enlace nun navegador Navega á folla de contido anterior - Personalizar gradiente - Tipo de degradado - Toca dúas veces para seleccionar a opción - Navega ao selector de cor personalizado Navega para personalizar o degradado + Navega ao selector de cor personalizado + Tipo de degradado Volver - Eu - Todos + Toca dúas veces para seleccionar a opción + Personalizar gradiente Autor da páxina - Estrutura do contido A miniatura do medio non se puido cargar + Estrutura do contido + Todos + Eu Rexeitar Non establecido As etiquetas axudan aos lectores dicíndolles de que se trata a entrada. + Data de publicación Engadir etiquetas Volver Gardar agora @@ -1660,474 +1657,473 @@ Language: gl_ES Data de publicación Cancelar Mover a borrador - Data de publicación As entradas na papeleira non se poden editar. Desexas cambiar o estado desta entrada a «borrador» para poder traballar nela? - Data de publicación - Programado - Na papeleira - Publicado - Feito Mover entrada a borrador? - Le o aviso de privacidade de CCPA - Selecciona algúns para continuar Elixe as túas temáticas Elixe as túas temáticas + Feito + Selecciona algúns para continuar + Publicado + Na papeleira + Programado + Data de publicación + Le o aviso de privacidade de CCPA A Ley de Privacidade do Consumidor de California («CCPA») obríganos a que proporcionemos información adicional aos residentes de California sobre as categorías de información personal que recompilamos e compartimos, onde obtemos esa información personal e como e por que a usamos. - Actualizar agora - Estado e visibilidade Aviso de privacidade para usuarios de California + Estado e visibilidade + Actualizar agora %1$s · Abrir o menú de accións de bloques Mover arriba Insertar unha mención - Toca dúas veces pata abrir a folla de acción coas opcións dispoñibles Toca dúas veces para abrir a folla inferior coas opcións dispoñibles - Páxina de entradas + Toca dúas veces pata abrir a folla de acción coas opcións dispoñibles + No podemos abrir as páxinas neste momento. Por favor, inténtao de novo máis tarde + Establecer como páxina de entradas + Establecer como páxina de inicio + %1$s non é unha %2$s válida Seleccionar a páxina - Blog clásico + Páxina de entradas Páxina de inicio estática - Establecer como páxina de inicio - Establecer como páxina de entradas + Blog clásico A páxina de inicio seleccionada e a páxina de entradas non poden ser a mesma. - No podemos abrir as páxinas neste momento. Por favor, inténtao de novo máis tarde - %1$s non é unha %2$s válida - Aceptar - Axustes da páxina de inicio - Fallou a carga das páxinas - Non se poden gardar os axustes da páxina de inicio - Non se poden gardar os axustes da páxina de inicio antes de que as páxinas estean cargadas Fallou a actualización da páxina de inicio, comproba a túa conexión a internet + Non se poden gardar os axustes da páxina de inicio antes de que as páxinas estean cargadas + Non se poden gardar os axustes da páxina de inicio + Aceptar + Fallou a carga das páxinas Elixe entre unha páxina de inicio que mostre as túas últimas publicacións (blog clásico) ou unha páxina fixa/estática. + Axustes da páxina de inicio Páxina de inicio - Fallou a actualización da páxina de inicio Fallou a actualización da páxina de entradas - Páxina de inicio actualizada correctamente Páxina de entradas actualizada correctamente - Para establecer a páxina de inicio, activa «Páxina de inicio estática» nos axustes do sitio + Fallou a actualización da páxina de inicio + Páxina de inicio actualizada correctamente Para establecer a páxina de entradas, activa «Páxina de inicio estática» nos axustes do sitio + Para establecer a páxina de inicio, activa «Páxina de inicio estática» nos axustes do sitio Seleccionar unha cor Toca dúas veces para ir aos axustes da cor Cando sigas sitios, verás aquí o seu contido - recortar - Insertar %d - Elixir o vídeo - Elixir o medio Saber máis - Usar este vídeo - Usar este medio + Que hai de novo en %s + Insertar %d + recortar + Erro ao cargar no arquivo, por favor, inténtao de novo. Vista previa da miniatura da imaxe + Usar este medio + Usar este vídeo + Elixir o medio + Elixir o vídeo Non se puido seleccionar o sitio. Por favor, inténtao de novo. - Erro ao cargar no arquivo, por favor, inténtao de novo. - Que hai de novo en %s - Copiar - Insertar - Continuar Continuar - Compartir en - Que hai de novo - Xestionar os sitios Fallou o reblog - Non se puido compartir - Copiar o enderezo do enlace - Copiada o enderezo do enlace - Non hai dispoñibles sitios WordPress.com + Xestionar os sitios Unha vez que crees un sitio en WordPress.com, podes volver a publicar o contido que che gusta no teu propio sitio. + Non hai dispoñibles sitios WordPress.com + Que hai de novo + Copiada o enderezo do enlace + Copiar o enderezo do enlace + Compartir en + Non se puido compartir + Insertar + Continuar + Copiar Número de columnas - Toca dúas veces para mover o bloque cara a esquerda - Toca dúas veces para mover o bloque cara a dereita - Mover o bloque á esquerda desde a posición %1$s á posición %2$s Mover o bloque á dereita desde a posición %1$s á posición %2$s - Mover bloque á esquerda Mover o bloque á dereita - Configurar o tema - Obtendo a URL do sitio + Mover o bloque á esquerda desde a posición %1$s á posición %2$s + Mover bloque á esquerda + Toca dúas veces para mover o bloque cara a dereita + Toca dúas veces para mover o bloque cara a esquerda + Axustes do bloque Creando o escritorio + Configurar o tema Engadindo as características do sitio + Obtendo a URL do sitio O teu sitio estará listo en breve Hurra!\nCase está feito - Axustes do bloque Cancelar a subida Houbo un problema ao xestionar a petición - Domingo - Luns - Venres - Martes + Funciona con Tenor + Elixir desde Tenor Sábado + Venres Xoves Mércores - Funciona con Tenor - Elixir desde Tenor - Accedendo ao contido dun sitio privado + Martes + Luns + Domingo Fallou o acceso ao contido dun sitio privado. Algúns medios poden non estar dispoñibles + Accedendo ao contido dun sitio privado Erro ao recortar e gardar a imaxe, por favor, inténtao de novo. + Erro ao cargar a imaxe.\nPor favor, toca para volver a intentalo. Previsualizar a imaxe Formato de páxina descoñecido + Non puidemos completar esta acción e non se enviou esta páxina a revisión. Non puidemos completar esta acción e non se programou esta páxina. Non puidemos completar esta acción e non se publicou esta páxina privada. - Non puidemos completar esta acción e non se enviou esta páxina a revisión. - Erro ao cargar a imaxe.\nPor favor, toca para volver a intentalo. - Non puidemos publicar esta páxina, pero intentarémolo de novo máis tarde. - Non puidemos programar esta páxina, pero intentarémolo de novo máis tarde. Non puidemos completar esta acción e non se publicou esta páxina. - Non puidemos publicar esta páxina privada, pero intentarémolo de novo máis tarde. Non puidemos enviar esta páxina a revisión, pero intentarémolo de novo máis tarde. + Non puidemos programar esta páxina, pero intentarémolo de novo máis tarde. + Non puidemos publicar esta páxina privada, pero intentarémolo de novo máis tarde. + Non puidemos publicar esta páxina, pero intentarémolo de novo máis tarde. Non puidemos subir este medio e non se enviou esta páxina a revisión. - Gardaremos o teu borrador cando o teu dispositivo volva a estar en liña - Non puidemos subir este medio e non se publicou a páxina. Non puidemos subir este medio e non se programou esta páxina. - Publicaremos a túa páxina privada cando o teu dispositivo volva a estar en liña. Non puidemos subir este medio e non se publicou esta páxina privada. + Non puidemos subir este medio e non se publicou a páxina. + Gardaremos o teu borrador cando o teu dispositivo volva a estar en liña + Publicaremos a túa páxina privada cando o teu dispositivo volva a estar en liña. + Programaremos a túa páxina cando o teu dispositivo volva a estar en liña. + Enviaremos a túa páxina para revisión cando o teu dispositivo volva a estar en liña. + Publicaremos a páxina cando o teu dispositivo volva a estar en liña. Páxina en espera Subindo a páxina O dispositivo está desconectado. A páxina gardouse localmente. Fixeches cambios non gardados nesta páxina - Publicaremos a páxina cando o teu dispositivo volva a estar en liña. - Programaremos a túa páxina cando o teu dispositivo volva a estar en liña. - Enviaremos a túa páxina para revisión cando o teu dispositivo volva a estar en liña. - Escuro - Claro - A páxina gardouse en liña - Establecido polo aforrador de batería - Páxina gardada no dispositivo A túa páxina estase subindo - Selecciona un blog para o atallo a QuickPress A páxina fallou ao subir os medios e gardouse localmente - Recentemente fixeches cambios nesta páxina, pero non os gardaches. Elixe unha versión para cargar:\n\n + Páxina gardada no dispositivo + A páxina gardouse en liña + Selecciona un blog para o atallo a QuickPress + Establecido polo aforrador de batería + Escuro + Claro Aparencia + Recentemente fixeches cambios nesta páxina, pero non os gardaches. Elixe unha versión para cargar:\n\n Mensaxe de advertencia Amosar o contido da entrada - Enlazar a Amosar só o extracto + Enlazar a Axustes da ligazón Lonxitude do extracto (palabras) Editar o medio da portada PERSONALIZAR URL do enlace do botón - Engadir un bloque de parágrafo Radio do borde + Engadir un bloque de parágrafo + Crear unha entrada Na papeleira Programada Publicada - Non conectado - Crear unha entrada A conexión con Facebook non pode encontrar ningunha páxina. Jetpack Social non pode conectar con perfís de Facebook, só con páxinas publicadas. + Non conectado Gústame - Papeleira - Non lido Seguimentos Comentarios + Non lido Non enviar á papeleira - Xeral + Papeleira Actividade - Engadir unha nova tarxeta Entradas e páxinas + Xeral + Engadir unha nova tarxeta Engadir unha nova tarxeta de estatísticas - Acceder a WordPress.com - Quitar o filtro actual + Usa o botón de filtro para encontrar entradas sobre temas específicos Selecciona unha etiqueta ou un sitio, ventá emergente Selecciona un sitio ou etiqueta para filtrar entradas - Usa o botón de filtro para encontrar entradas sobre temas específicos + Quitar o filtro actual Xestionar etiquetas e sitios - Accede a WordPress.com para ver as últimas entradas dos sitios que segues + Acceder a WordPress.com Accede a WordPress.com para ver as últimas entradas dos temas que segues + Accede a WordPress.com para ver as últimas entradas dos sitios que segues + Substituír o bloque actual Engadir ao final - Seguir un sitio - Engadir o bloque despois - Engadir o bloque antes Engadir ao principio - Substituír o bloque actual - Mira as entradas máis recentes dos sitios que segues + Engadir o bloque antes + Engadir o bloque despois Engadir un tema + Seguir un sitio Podes seguir entradas sobre un tema específico engadindo un tema - Filtrar + Mira as entradas máis recentes dos sitios que segues Seguindo + Filtrar + Lenda do vídeo. %s Editar o vídeo Editar os medios - Lenda do vídeo. %s Engadir un shortcode… - baixas - altas - medias - moi altas Autor da entrada Crear unha entrada -   e %1$d %2$s - Actividade de publicación para %1$s - explora todas as estatísticas para este período + Escoitaches todas as estatísticas deste período.\n Se volves a tocar, reiniciarase desde o principio. Non hai estatísticas neste período. + Actividade de publicación para %1$s Os días con visitas %1$s para %2$s son: %2$s %3$s. Toca para máis. - Escoitaches todas as estatísticas deste período.\n Se volves a tocar, reiniciarase desde o principio. + explora todas as estatísticas para este período + moi altas + altas + medias + baixas +   e %1$d %2$s %1$s, %2$d %3$s Lenda da galería. %s Crear unha entrada ou páxina - Agora non Creador da web + Agora non Calquera cousa que queiras crear ou compartir, axudarémosche a facelo aquí mesmo. + Benvido a WordPress Biblioteca de fotos Imaxe non seleccionada - Benvido a WordPress - Engadir nova - Entrada do blog , seleccionada Imaxe seleccionada Miniatura da imaxe + Entrada do blog + Engadir nova Publicar Sincronizar agora - Preparado para sincronizar? Esta entrada sincronizarase inmediatamente. - -%s + Preparado para sincronizar? Este dominio non está dispoñible + -%s Non puidemos acceder ao teu sitio. Terás que contactar co teu aloxamento para solucionalo. - Non puidemos acceder ao teu sitio porque necesita <b>identificación HTTP</b>. Terás que contactar co teu aloxamento para solucionalo. Non puidemos acceder ao teu sitio debido a un problema co <b>certificado SSL</b>. Terás que contactar co teu aloxamento para solucionalo. - Seguindo - Páxina do sitio - Accede coas credenciais do teu sitio %1$s - Xa case estamos! Só necesitamos verificar o teu enderezo de correo electrónico conectado a Jetpack <b>%1$s</b> + Non puidemos acceder ao teu sitio porque necesita <b>identificación HTTP</b>. Terás que contactar co teu aloxamento para solucionalo. Non puidemos acceder no teu sitio ao <b>arquivo XMLRCP</b>. Terás que contactar co teu aloxamento para solucionalo. - Gardados + Xa case estamos! Só necesitamos verificar o teu enderezo de correo electrónico conectado a Jetpack <b>%1$s</b> + Accede coas credenciais do teu sitio %1$s + Páxina do sitio + Seguindo Gústame Descubre + Gardados + Temas + Sitios + %sE + %sP + %sT %sG %sM - %sT - %sP - %sE - Sitios + %sK Non podemos abrir as entradas neste momento. Por favor, inténtao de novo máis tarde Non puidemos cargar os datos para o teu sitio neste momento. Por favor, inténtao de novo máis tarde - Temas - %sK Biblioteca de medios de WordPress + Non compatible Desagrupar + Toca para ocultar o teclado + Toca aquí para amosar a axuda Fai un vídeo + Fai unha foto ou un vídeo Fai unha foto Empeza a escribir… - Toca aquí para amosar a axuda - Fai unha foto ou un vídeo - Toca para ocultar o teclado - Non compatible - Bloque %s. Baleiro Bloque %s. Este bloque ten contido non válido + Bloque %s. Baleiro Cortar bloque - Pegar a URL - Abrir os axustes + Problema ao abrir o vídeo + Problema ao amosar o bloque Título da entrada. %s Título da entrada. Baleiro + Pegar a URL Bloque de salto de páxina. %s - Problema ao amosar o bloque - Problema ao abrir o vídeo + Abrir os axustes Ningunha aplicación pode manexar esta petición. Por favor, instala un navegador web. Navegar arriba Mover o bloque cara arriba, da fila %1$s á fila %2$s - Mover o bloque cara abaixo, da fila %1$s á fila %2$s Mover o bloque arriba - Icona de axuda + Mover o bloque cara abaixo, da fila %1$s á fila %2$s + Mover o bloque abaixo + Texto do enlace Enlace insertado - Ocultar o teclado Lenda da imaxe. %s + Ocultar o teclado + Icona de axuda Toca dúas veces para desfacer o último cambio - Mover o bloque abaixo - Texto do enlace - Toca dúas veces para seleccionar Toca dúas veces para alternar os axustes - Toca dúas veces para seleccionar un vídeo Toca dúas veces para seleccionar unha imaxe + Toca dúas veces para seleccionar un vídeo + Toca dúas veces para seleccionar Toca dúas veces para refacer o último cambio - Toca dúas veces para engadir un bloque - Toca dúas veces e mantén para editar - Toca dúas veces para editar este valor Toca dúas veces para mover o bloque cara arriba Toca dúas veces para mover o bloque cara abaixo - Elixir desde o dispositivo + Toca dúas veces para editar este valor + Toca dúas veces para engadir un bloque + Toca dúas veces e mantén para editar O valor actual é %s - Engadir a URL - Texto alternativo - ENGADIR O BLOQUE AQUÍ + Elixir desde o dispositivo Ocorreu un erro descoñecido. Por favor, inténtao de novo. + Texto alternativo + Engadir vídeo + Engadir a URL Engadir o texto alternativo + ENGADIR O BLOQUE AQUÍ Engadir descrición - Engadir vídeo - A lista cargouse con %1$d elementos. Toca o botón «Engadir ás entradas gardadas» para gardar unha entrada na túa lista. + A lista cargouse con %1$d elementos. Notificacións - Activado Desactivado + Activado Ao desactivar os avisos para este sitio, desactivaranse os avisos mostrados na pestana de avisos deste sitio. Podes axustar que tipo de aviso ves despois de activar os avisos para este sitio. - Avisos para este sitio - Avisos para este sitio + Para ver os avisos na pestana de avisos deste sitio, activa os avisos para este sitio. Activar os avisos mostrados na pestana de avisos deste sitio Desactivar os avisos mostrados na pestana de avisos deste sitio - Para ver os avisos na pestana de avisos deste sitio, activa os avisos para este sitio. + Avisos para este sitio + Avisos para este sitio Engadir unha imaxe ou un vídeo + Non puidemos enviar esta entrada para revisión, pero intentarémolo de novo máis tarde. Non puidemos programar esta entrada, pero intentarémolo de novo máis tarde. Non puidemos publicar esta entrada privada, pero intentarémolo de novo máis tarde. - Non puidemos enviar esta entrada para revisión, pero intentarémolo de novo máis tarde. Non puidemos publicar esta entrada, pero intentarémolo de novo máis tarde. - Non puidemos completar esta acción e non se publicou esta entrada. + Non puidemos completar esta acción e non se enviou esta entrada para revisión. Non puidemos completar esta acción e non se programou esta entrada. Non puidemos completar esta acción e non se enviou esta entrada privada. - Non puidemos completar esta acción e non se enviou esta entrada para revisión. - Non puidemos subir este medio. - Non puidemos subir este medio e non se publicou a entrada. + Non puidemos completar esta acción e non se publicou esta entrada. + Non puidemos subir este medio e non se enviou esta entrada para revisión. Non puidemos subir este medio e non se programou esta entrada. Non puidemos subir este medio e non se publicou esta entrada privada. - Non puidemos subir este medio e non se enviou esta entrada para revisión. - Vista previa non dispoñible - Xerando a vista previa… - Non se pode previsualizar unha entrada baleira - Non se pode previsualizar unha páxina baleira - Non se pode previsualizar un borrador baleiro + Non puidemos subir este medio e non se publicou a entrada. + Non puidemos subir este medio. + Non puidemos completar esta acción, pero intentarémolo de novo máis tarde. Non puidemos completar esta acción. + Non se pode previsualizar un borrador baleiro + Non se pode previsualizar unha páxina baleira + Non se pode previsualizar unha entrada baleira + Vista previa non dispoñible Erro ao intentar gardar a entrada antes de previsualizala - Non puidemos completar esta acción, pero intentarémolo de novo máis tarde. + Xerando a vista previa… Gardando… Fixeches cambios non gardados nesta entrada - Borrar permanentemente A versión desde esta aplicación A versión desde outro dispositivo + Desde esta aplicación\nGardado en %1$s\n\nDesde outro dispositivo\nGardado en %2$s\n + Recentemente fixeches cambios nesta entrada, pero non os gardaches. Elixe unha versión para cargar:\n\n Que versión che gustaría editar? + Borrar permanentemente Non gardaremos os últimos cambios no teu borrador. - Recentemente fixeches cambios nesta entrada, pero non os gardaches. Elixe unha versión para cargar:\n\n - Desde esta aplicación\nGardado en %1$s\n\nDesde outro dispositivo\nGardado en %2$s\n - Non publicaremos estes cambios. Non programaremos estes cambios. Non enviaremos estes cambios para revisión. + Non publicaremos estes cambios. Gardaremos o teu borrador cando o teu dispositivo volva a estar en liña Publicaremos a túa entrada privada cando o teu dispositivo volva a estar en liña. - Publicaremos a entrada cando o teu dispositivo volva a estar en liña. Programaremos a túa entrada cando o teu dispositivo volva a estar en liña. Enviaremos a túa entrada para revisión cando o teu dispositivo volva a estar en liña. - Gardando o nome de usuario… - O teu novo nome de usuario é %1$s + Publicaremos a entrada cando o teu dispositivo volva a estar en liña. Esta acción non pode cancelarse. É posible que o nome de usuario xa se actualizara. - Coidado! + O teu novo nome de usuario é %1$s + Gardando o nome de usuario… Cambiar o nome do usuario - Rendemento e velocidade - Ver e cambiar os axustes de rendemento de Jetpack - Estás a punto de cambiar o teu nome de usuario, que actualmente é %1$s%2$s%3$s. Non poderás volver a recuperar o teu nome de usuario. Estás cambiando o teu nome de usuario a %1$s%2$s%3$s. Cambiar o teu nome de usuario tamén afectará ao teu perfil de Gravatar e ás direccións de perfil de Intense Debate. Para continuar, confirma o teu novo nome de usuario. - Desactivados + Coidado! + Estás a punto de cambiar o teu nome de usuario, que actualmente é %1$s%2$s%3$s. Non poderás volver a recuperar o teu nome de usuario. + Ver e cambiar os axustes de rendemento de Jetpack + Rendemento e velocidade Máis - Medios - Imaxes máis rápidas - Busca de Jetpack + Substitúe a busca integrada en WordPress cunha experiencia mellorada de busca Busca mellorada - Arquivos estáticos máis rápidos + Busca de Jetpack Aloxamento de vídeo sen anuncios - Substitúe a busca integrada en WordPress cunha experiencia mellorada de busca + Medios Carga as páxinas máis rápido ao permitir a Jetpack optimizar as túas imaxes e arquivos estáticos (como CSS e JavaScript). + Arquivos estáticos máis rápidos + Imaxes máis rápidas + Desactivados Activados - Rendemento Acelerador de sitios Mellora a velocidade do teu sitio ao cargar só as imaxes visibles na pantalla. + Rendemento Descargas Arquivo Descargas de arquivos - Zona horaria do sitio (UTC) - Zona horaria do sitio (UTC +%s) - Zona horaria do sitio (UTC -%s) As estatísticas de descarga de arquivos non se rexistraron antes do 28 de xuño de 2019. - Compartir + Zona horaria do sitio (UTC -%s) + Zona horaria do sitio (UTC +%s) + Zona horaria do sitio (UTC) + Escritorio Por defecto + Pechar o diálogo + Seleccionar o tipo de vista previa + Compartir Volver - Escritorio Avanzar - Seleccionar o tipo de vista previa + «%1$s» programado para publicar o «%2$s» na túa aplicación de %3$s\n%4$s Entrada programada de WordPress: «%s» «%s» publicarase en 10 minutos - «%1$s» programado para publicar o «%2$s» na túa aplicación de %3$s\n%4$s - Pechar o diálogo - Cando se publique - Entrada programada + «%s» publicarase en 1 hora Publicouse «%s» - Entrada programada: recordatorio de 1 hora Entrada programada: recordatorio de 10 minutos - «%s» publicarase en 1 hora + Entrada programada: recordatorio de 1 hora + Entrada programada O aviso non pode crearse cando a data de publicación xa pasou. - Desactivados - Aviso + Cando se publique + 10 minutos antes 1 hora antes + Desactivados Engadir ao calendario - 10 minutos antes + Aviso Data e hora Por favor, introduce un enderezo completo dunha web, como exemplo.gal. - Entrada + Accede con WordPress.com para conectar con %1$s Visitas - Editor - Ampliar - Contraer - Elemento expandido + Entrada + %1$s: %2$s, %3$s: %4$s Elemento contraído + Elemento expandido + Contraer + Ampliar Gráfico actualizado. - %1$s: %2$s, %3$s: %4$s - Cargando os datos da tarxeta seleccionada - Accede con WordPress.com para conectar con %1$s %1$s %2$s do período: %3$s, cambio desde o período anterior - %4$s + Cargando os datos da tarxeta seleccionada + Editor Ampliar Contraer - Verifica o teu enderezo de correo electrónico - as instrucións enviáronse a %s Verifica o teu enderezo de correo electrónico - as instrucións enviáronse ao teu correo electrónico - http(s):// - Aceptar + Verifica o teu enderezo de correo electrónico - as instrucións enviáronse a %s Cancelar - Insertar enlace + Aceptar + http(s):// Quitar enlace + Insertar enlace Reintentar a subida Subindo medios.\nPor favor, toca para ver as opcións. Abrir enlace nunha nova ventá/pestana Para ver as túas estatísticas accede á conta de WordPress.com. - Hoxe - Histórico - Dun vistazo - Buscar entradas Ningunha entrada coincide coa túa busca + Buscar entradas Aquí é onde a xente te encontra en Internet. Elixe un nome de dominio personalizado Todos os plans anuais de WordPress.com inclúen un nome de dominio personalizado. Rexistra agora o teu dominio gratuíto. - Escuro - Sitio + Dun vistazo + Hoxe + Histórico + Visitas esta semana + Por favor, accede á aplicación WordPress para engadir un widget. + Non hai ningunha rede dispoñible + Non se pudieron cargar os datos Tipo Cor + Selecciona o teu sitio + Escuro Claro Cor + Selecciona o teu sitio + Sitio Histórico - Engadir widget Visitas esta semana - Visitas esta semana - Selecciona o teu sitio - Selecciona o teu sitio - Non se pudieron cargar os datos - Non hai ningunha rede dispoñible - Por favor, accede á aplicación WordPress para engadir un widget. + Engadir widget Está levando máis tempo do normal recargar os detalles do plugin. Por favor, compróbao de novo máis tarde. Se acabas de rexistrar un nome de dominio, por favor, espera ata que terminemos de configuralo e inténtao de novo.\n\nEn caso contrario, parece que algo saíu mal e a característica do plugin podería non estar dispoñible para este sitio. Estado (non dispoñible) - Non se puido cargar esta páxina neste momento. - Comproba a túa conexión á rede e inténtao de novo. - Ao configurar Jetpack aceptas os nosos %1$stermos e condicións%2$s Ao rexistrar este dominio aceptas os nosos %1$stermos e condicións%2$s + Comproba a túa conexión á rede e inténtao de novo. + Non se puido cargar esta páxina neste momento. Non se puideron recuperar os axustes. Algunhas APIs non están dispoñibles para a conta e ID desta aplicación OAuth. - Actualizar contrasinal - Contrasinal actualizado + Ao configurar Jetpack aceptas os nosos %1$stermos e condicións%2$s Non hai ningunha conexión. A edición está desactivada. Para volver a conectar a aplicación co teu sitio aloxado, introduce aquí o novo contrasinal do sitio. + Contrasinal actualizado + Actualizar contrasinal Rexistrando o nome de dominio… Selecciona a provincia Selecciona o país + Rexistrar un dominio Código Postal + Provincia Cidade Enderezo 2 - Provincia - Teléfono Enderezo País Código do país - Rexistrar un dominio + Teléfono Organización (opcional) Para a túa comodidade, completamos a túa información de contacto\n de WordPress.com. Por favor, comproba que é a información correcta que queres usar para este dominio. - Rexistrar publicamente Información de contacto do dominio + Rexistrar publicamente Rexistrar privadamente con protección de privacidade Os propietarios de dominios teñen que compartir información nunha base de datos pública de todos os dominios.\n Coa protección de privacidade publicamos a nosa propia información en vez da túa, e redirixirémosche de forma privada calquera comunicación dirixida a ti. Protección da privacidade @@ -2135,8 +2131,8 @@ Language: gl_ES Novo Descartar Próbao agora - Xestiona as túas estatísticas Elixe que estatísticas ver, e céntrate nos datos que máis te preocupen. Toca en %1$s ao fondo das estatísticas para personalizalas. + Xestiona as túas estatísticas Recuperando revisións… Fallo ao insertar os medios.\nPor favor, toca para ver as opcións. Fallo ao insertar os medios.\nPor favor, toca para volver a intentalo. @@ -2187,6 +2183,7 @@ Language: gl_ES Rexistrar dominio Para instalar plugins necesitas ter un dominio personalizado asociado ao teu sitio. Instalar plugin + Poderás personalizar a aparencia do teu sitio máis adiante Publicar o: %s Programada para o: %s Publicado o: %s @@ -2197,7 +2194,6 @@ Language: gl_ES Período Meses e anos Cargar máis - Poderás personalizar a aparencia do teu sitio máis adiante Hoxe Mellor hora Mellor día @@ -2211,540 +2207,540 @@ Language: gl_ES O sitio aínda non se cargou Máis entradas Menos entradas + Podes perder o que levas feito. Estás seguro de que queres saír? Ver os plans + Requírese unha conexión a Internet para ver os plans, así que os detalles poderían estar desactualizados. Requírese unha conexión a Internet para ver os plans. - Podes perder o que levas feito. Estás seguro de que queres saír? Non podemos cargar os plans neste momento. Por favor, inténtao de novo. - Requírese unha conexión a Internet para ver os plans, así que os detalles poderían estar desactualizados. - Non hai conexión - Datos non cargados - Usar editor de bloques Non se poden cargar os plans + Non hai conexión Cambiar ao editor de bloques - Edita as novas entradas e páxinas co editor de bloques Houbo un problema ao cargar os teus datos, recarga a páxina e inténtao de novo. + Datos non cargados + Edita as novas entradas e páxinas co editor de bloques + Usar editor de bloques saír - Seguintes pasos - Os teus visitantes verán a túa icona no seu navegador. Engade unha icona personalizada para conseguir un aspecto profesional e refinado. Fai crecer a túa audiencia Personaliza o teu sitio + Seguintes pasos Elixe unha icona do sitio único - Toca en %1$s Icona do teu sitio %2$s para subir un novo + Os teus visitantes verán a túa icona no seu navegador. Engade unha icona personalizada para conseguir un aspecto profesional e refinado. Toca en %1$s Estatísticas %2$s para ver como está rendendo o teu sitio. + Toca en %1$s Icona do teu sitio %2$s para subir un novo Garda en borrador e publica unha entrada. Activar compartir entradas Comparte automaticamente as novas entradas nas túas contas de medios sociais. Revisa as estatísticas do teu sitio Mantente ao día sobre o rendemento do teu sitio. - Recordatorio Saltar tarefa - Hora máis popular + Recordatorio Elixe o seguinte período Elixe o período anterior + %1$s de vistas + Hora máis popular %1$s (%2$s) +%1$s (%2$s) - %1$s de vistas - Baleirar - Crear sitio - Crear sitio - Houbo un problema Mostrando a vista previa + Baleirar + Parece que tes unha conexión lenta. Se non ves o teu novo sitio na lista, inténtao actualizando. Cancelar o asistente de creación de sitios Estamos creando o teu novo sitio + Houbo un problema + Crear sitio + Crear sitio Aquí é onde a xente te encontra en Internet. - Parece que tes unha conexión lenta. Se non ves o teu novo sitio na lista, inténtao actualizando. - Houbo un problema Non hai direccións dispoñibles que coincidan coa túa busca Erro durante a comunicación co servidor. Inténtao de novo + Houbo un problema Houbo un problema - Crear sitio + Creouse o teu sitio! %1$d de %2$d - Conflicto de versións + Crear sitio Suxerencias actualizadas - Creouse o teu sitio! Non se puido seleccionar o sitio auto-hospedado que acabas de engadir - Desfacer - Descartar web + Conflicto de versións + Activa os informes de erros automáticos para axudarnos a mellorar o rendemento da app. Informes de erros - Actualizando contido + Desfacer Versión web descartada Versión local descartada - Activa os informes de erros automáticos para axudarnos a mellorar o rendemento da app. + Actualizando contido + Descartar web Descartar local - Resolver conflicto de sincronización - Este contido ten dúas versións en conflito. Selecciona que versión queres descartar.\n Local\nGardado o %1$s\n\nWeb\nGardado o %2$s\n + Este contido ten dúas versións en conflito. Selecciona que versión queres descartar.\n + Resolver conflicto de sincronización Non hai datos neste período Eliminar a ubicación dos medios Non podemos abrir as estatísticas neste momento. Por favor, inténtao de novo máis tarde + Ningún medio coincide coa túa busca + Busca para encontrar GIF para engadir á túa biblioteca de medios! + Visitas Autor Autores - Tumblr - Twitter - Facebook - Path - LinkedIn - Google+ - Visitas - Visitas - Título Visitas + Buscar termo + Buscar termos Visitas - Visitas + Título Vídeos - Clics - Clics + Visitas País + Países + Clics + Ligazón + Clics + Visitas Referente - Ver máis Referentes - Países - Compartir entrada - Buscar termo - Crear entrada - Buscar termos Entradas e páxinas - Ningún medio coincide coa túa busca - Busca para encontrar GIF para engadir á túa biblioteca de medios! - Pasaron %1$s desde que se publicou %2$s. Así é como funcionou a entrada ata agora: - Ligazón - Histórico - Etiquetas e categorías + Path + LinkedIn + Google+ + Tumblr + Twitter + Facebook + Ver máis + Compartir entrada + Crear entrada + Pasaron %1$s desde que se publicou %2$s. Así é como funcionou a entrada ata agora: Pasaron %1$s desde que se publicou %2$s. Pon a bola a rodar e aumenta as vistas das entradas compartindo a túa entrada: + Etiquetas e categorías + Histórico + %1$s - %2$s + Seguidores + Servizo + %1$s | %2$s + Visitas + Título + Visitas + Título + Comentarios + Título Autor + Entradas e páxinas Autores - WordPress.com - Título - Título - Visitas - Título Desde - Correo electrónico - Visitas - Servizo Seguidor - Comentarios - Seguidores - %1$s | %2$s - %1$s - %2$s - Xestionar datos - Entradas e páxinas Total %1$s seguidores: %2$s - Aínda non hai datos + Correo electrónico + WordPress.com + Xestionar datos Aínda non se engadiron impresións + Aínda non hai datos Menú de depuración Cambiando contrasinal… - Cambiar contrasinal - Contrasinal cambiado con éxito O teu contrasinal debe ter polo menos seis caracteres de lonxitude. Para facelo máis forte, usa letras maiúsculas e minúsculas, números e símbolos coma ! \" ? $ % ^ & ). + Contrasinal cambiado con éxito + Cambiar contrasinal Nome (sen titulo) Vista previa HTML Vista previa visual Revision - Seguinte Anterior + Seguinte %1$s utilizado - Cargar - Aínda non hai histórico - Revisión cargada + Por favor, introduce un sitio WordPress WordPress.com ou autoaloxado conectado a Jetpack Cargando revisión + Revisión cargada + Cargar Entrada creada o %1$s ás %2$s Páxina creada o %1$s ás %2$s + Aínda non hai histórico Cando fas cambios á túa entrada poderás ver aquí o histórico Cando fas cambios á túa páxina poderás ver aquí o histórico - Por favor, introduce un sitio WordPress WordPress.com ou autoaloxado conectado a Jetpack Avatar do usuario + Tamaño completo Grande Mediano - Historia - Tamaño completo Miniatura - Pendente de revisión + Historia A páxina seleccionada non está dispoñible - Buscar páxinas - Mover a borradores - Borrar permanentemente - Ningunha páxina coincide coa túa busca - Non tes ningunha páxina en borrador + Pendente de revisión Non tes ningunha páxina na papeleira Non tes ningunha páxina programada + Non tes ningunha páxina en borrador Todavía non publicaches ningunha páxina + Buscar páxinas + Ningunha páxina coincide coa túa busca + Borrar permanentemente Mover á papeleira + Mover a borradores + Facer superior Ver - Publicadas - Borradores - Programadas No lixo - Facer superior + Programadas + Borradores + Publicadas Fixemos demasiados intentos para enviar un código de verificación por SMS - tómate un descanso e solicita un novo dentro dun minuto. - A páxina superior cambiou - Ningún sitio coincide coa túa busca - Ningún sitio coincide coa túa busca Non hai ningunha conta de WordPress.com que coincida con esta conta de Google. - Nivel superior - Facer superior + Ningún sitio coincide coa túa busca + Ningún sitio coincide coa túa busca + A páxina superior cambiou + A páxina borrouse permanentemente + A páxina foi programada + A páxina foi publicada A páxina enviouse á papeleira A páxina moveuse a borradores - A páxina borrouse permanentemente - Houbo un problema ao borrar a páxina + Nivel superior Estás seguro de querer borrar a páxina %s? Houbo un problema ao cambiar a páxina superior Houbo un problema ao cambiar o estado da páxina + Houbo un problema ao borrar a páxina + Facer superior Descartar - A páxina foi programada - A páxina foi publicada toca aquí Crea o teu sitio Pon o teu sitio en marcha. A que se sinte un ben terminando unha lista? Ver o teu sitio + Previsualiza o teu sitio para ver o que verán os teus visitantes. Comparte o teu sitio + Toca en %1$s Social %2$s para continuar Toca en %1$s Conexións %2$s para engadir as túas contas de medios sociais Conecta coas túas contas de medios sociais - o teu sitio compartirá automaticamente as novas entradas. - Previsualiza o teu sitio para ver o que verán os teus visitantes. - Toca en %1$s Social %2$s para continuar Publica unha entrada Toca en %1$s Crear entrada %2$s para crear unha nova entrada - Segue outros sitios Non, grazas + Segue outros sitios Ir - Máis Cancelar Agora non + Máis Non tes sitios Temas non seguidos Engade aquí temas para descubrir entradas sobre as túas temáticas favoritas + Accede á conta de WordPress.com que usaches para conectar con Jetpack. Jetpack FAQ de Jetpack - Accede á conta de WordPress.com que usaches para conectar con Jetpack. Para usar as estatísticas no teu sitio WordPress necesitas instalar o plugin Jetpack. - Crea unha etiqueta - Saír de WordPress? - No tes ningunha etiqueta + Non hai temas que coincidan coa túa busca Que che gustaría encontrar? Non hai etiquetas que coincidan coa túa busca - Ningún medio coincide coa túa busca - Non hai temas que coincidan coa túa busca + No tes ningunha etiqueta Engade aquí as etiquetas que uses frecuentemente para poder seleccionalas rapidamente ao etiquetar as túas entradas + Crea unha etiqueta + Ningún medio coincide coa túa busca + Saír de WordPress? Tes cambios en entradas que non se subiron ao teu sitio. Saír agora borrará eses cambios do teu dispositivo. Queres saír de todos modos? - Aínda non hai usuarios Aínda non hai lectores - Aínda non tes seguidores Aínda non hai seguidores por correo electrónico + Aínda non tes seguidores + Aínda non hai usuarios As entradas que che gusten aparecerán aquí - Descubre sitios + Aínda non che gustou nada Ir aos seguintes + Descubre sitios Sitios que non segues - Aínda non che gustou nada Aínda non hai gústame Aínda non hai seguidores Como estás nun plan gratuíto verás eventos limitados na túa actividade. - Aínda non hai actividade Cando fagas cambios no teu sitio poderás ver o historial da túa actividade aquí - Sube medios + Aínda non hai actividade Crear unha entrada Crea unha páxina + Sube medios Non tes ningún medio + galería de imaxes icona do sitio imaxe do tema - galería de imaxes imaxe destacada Descartar foto de perfil + Dato transitorio Email + Por favor, introduce o teu enderezo de correo electrónico + Para continuar, por favor, introduce o teu enderezo de correo electrónico e o nome + Novo mensaxe de «Axuda e soporte» WordPress Non establecido - Dato transitorio Correo electrónico de contacto - Por favor, introduce o teu enderezo de correo electrónico - Novo mensaxe de «Axuda e soporte» - Para continuar, por favor, introduce o teu enderezo de correo electrónico e o nome Restauración en progreso Restaurando a %1$s %2$s - Botón de acción do rexistro de actividade Actualmente restaurando o teu sitio O teu sitio restaurouse satisfactoriamente O teu sitio restaurouse con éxito\nRebobinado a %1$s %2$s O teu sitio está a ser restaurado\nRebobinando a %1$s %2$s + Botón de acción do rexistro de actividade Xestionado automaticamente Garda esta entrada e volve cando queiras para lela. Só estará dispoñible neste dispositivo — as entradas gardadas non se sincronizan con outros dispositivos. - Non se encontraron resultados Gardar entradas para máis tarde Non se puido realizar a busca - Sitios + Non se encontraron resultados Le a entrada de orixe + Sitios Enviado enlace máxico - Enviado enlace máxico - Acceso por enlace máxico Verificación do código Credenciais de acceso + Enviado enlace máxico + Acceso por enlace máxico Acceso mediante o enderezo do sitio Acceso mediante enderezo de correo electrónico - Borrado - Ver todas - Entrada gardada - Entradas gardadas - Engadir ás entradas gardadas + Toca %s para gardar unha entrada na túa lista. Aínda non hai entradas gardadas! + Entrada gardada + Ver todas Borrada das entradas gardadas - Toca %s para gardar unha entrada na túa lista. - Eliminar + Engadir ás entradas gardadas + Entradas gardadas + Borrado + Cambiar icona do sitio Cancelar - Activar + Eliminar Cambiar - este sitio - Icona do sitio - Cambiar icona do sitio - Gustaríache engadir unha icona do sitio? - Como che gustaría editar a icona? - Non tes permiso para engadir unha icona ao sitio. Non tes permiso para editar a icona do sitio. - Evento + Non tes permiso para engadir unha icona ao sitio. + Como che gustaría editar a icona? + Gustaríache engadir unha icona do sitio? + Icona do sitio + este sitio + Activar + Activar avisos para %1$s%2$s%3$s? + Activar os avisos do sitio + Desactivar os avisos do sitio Icona de Jetpack - Rexistro de actividade + Evento Icona de actividade - Política de cookies - Política de privacidade - Axustes de privacidade - Política de terceiros - Recompilar información + Rexistro de actividade Le a política de privacidade - Activar os avisos do sitio - Desactivar os avisos do sitio - Activar avisos para %1$s%2$s%3$s? Usamos outras ferramentas de seguimento, incluídas algunhas de terceiros. Le acerca destas e como controlalas. - Comparte información coa nosa ferramenta de análise acerca do uso que fas dos servizos mentres estás conectado á túa conta de WordPress.com. + Política de terceiros Esta información axúdanos a mellorar os nosos produtos, facer que o marketing sexa máis relevante, personalizar a túa experiencia en WordPress.com e máis, tal como se detalla na nosa política de privacidade. + Política de privacidade + Comparte información coa nosa ferramenta de análise acerca do uso que fas dos servizos mentres estás conectado á túa conta de WordPress.com. + Política de cookies + Axustes de privacidade + Recompilar información Entrada enviada Unha característica do plugin require que o sitio estea en bo estado. Unha característica do plugin necesita que a subscrición do dominio principal estea asociada con este usuario. - O plugin non pode instalarse en sitios VIP. - Unha característica do plugin require un dominio personalizado. - Unha característica do plugin require un plan business. Unha característica do plugin necesita privilexios de administrador. - Unha característica do plugin require que o sitio sexa público. - Unha característica do plugin require un enderezo de correo electrónico verificado. + O plugin non pode instalarse en sitios VIP. O plugin non se pode instalar debido ás limitacións de espazo do disco. + Unha característica do plugin require un enderezo de correo electrónico verificado. + Unha característica do plugin require que o sitio sexa público. + Unha característica do plugin require un plan business. + Unha característica do plugin require un dominio personalizado. Estamos facendo a configuración final, está case listo… Instalando plugin… Instalar Instalar o primeiro plugin no teu sitio pode levar ata 1 minuto. Durante este tempo non poderás realizar cambios no teu sitio. + Instalar plugin Notificacións - Diariamente + Enviarme novos comentarios por correo electrónico Semanalmente Instantaneamente + Diariamente Novas entradas - Instalar plugin - Enviarme novas entradas por correo electrónico - Enviarme novos comentarios por correo electrónico Recibe avisos das novas entradas deste sitio - Sitios seguidos + Enviarme novas entradas por correo electrónico Todos os meus sitios seguidos - Xente mirando gráficos e táboas + Sitios seguidos Dispositivo de lectura personal con avisos - Seguro que queres eliminar definitivamente esta publicación? + Xente mirando gráficos e táboas %1$s en %2$s - Xeral + Seguro que queres eliminar definitivamente esta publicación? Importante + Xeral Utilizar esta foto %1$d de %2$d - Engadir %d - Vista previa %d - %1$s de ilimitado Fotografías facilitadas por %s + Busca para encontrar fotografías gratuítas para engadir á túa biblioteca de medios Busca na biblioteca de fotos gratuítas - Non se pode gardar un borrador baleiro Selecciona da biblioteca gratuíta de fotos - Busca para encontrar fotografías gratuítas para engadir á túa biblioteca de medios + Non se pode gardar un borrador baleiro + %1$s de ilimitado + Vista previa %d + Engadir %d Crear etiqueta + navegar cara arriba Notificacións - reproduce - reintentar - papeleira - audio + Abrir enlace externo + amosar máis foto borrar - vista previa - eliminar %s - información do perfil - amosar máis Reproducir vídeo - reproducir vídeo - marca de verificación - abrir cámara + reproducir vídeo destacado logo do plugin - navegar cara arriba banner do plugin - previsualizar imaxe - elixe desde o dispositivo - Abrir enlace externo - reproducir vídeo destacado - Imaxe de perfil de %s - Rexístrarte con Google… elixe desde medios de WordPress + abrir cámara + elixe desde o dispositivo + información do perfil + reproduce + previsualizar imaxe + vista previa + audio + reproducir vídeo + papeleira + reintentar vista previa de medios, nome do arquivo %s + eliminar %s + Imaxe de perfil de %s + marca de verificación + Rexístrarte con Google… Fallou a conexión a Jetpack: %s Xa estás conectado a Jetpack - %s TB - Vista previa - Modo HTML Modo visual + Modo HTML + Vista previa Gardar coma borrador + %s TB %s GB %s MB %s kB - %1$s de %2$s %s B - Medios - Elixe o sitio + %1$s de %2$s + Se necesitas máis espazo, considera actualizar o teu plan de WordPress. Espazo utilizado - Editar foto - Nova conta - O comentario gustou - O comentario non gustou + Medios + Comentario marcado como non spam + Comentario marcado como spam Comentario borrado Comentario restaurado - Comentario aprobado - Comentario sen aprobar Comentario enviado á papeleira - Comentario marcado como spam + O comentario non gustou + O comentario gustou + Comentario sen aprobar + Comentario aprobado Detalle de notificación %s - Comentario marcado como non spam - Se necesitas máis espazo, considera actualizar o teu plan de WordPress. - Lector - Notificacións - Eu - Detalles do arquivo + Editar foto + Elixe o sitio + Nova conta Conectado como Detalle da persoa + Detalles do arquivo Botóns de compartir - Axustes de avisos + Notificacións + Lector + Eu O meu sitio + Axustes de avisos O teu avatar subiuse e estará dispoñible en breve. - Version %s - Destacados - Permisos Parece que desactivaches os permisos necesarios para esta característica.<br/><br/>Para cambialo, edita os teus permisos e asegúrate de que <strong>%s</strong> estea activado. - Módulo Social desactivado. + Permisos + Destacados Non podes acceder aos teus axustes para compartir en redes sociais porque o teu módulo Jetpack Social está desactivado. + Módulo Social desactivado. + Version %s O son escollido ten unha ruta incorrecta. Por favor, elixe un distinto. QP %s + quedan %1$d páxinas / entradas Queda 1 páxina quedan %1$d páxinas quedan %1$d entradas - quedan %1$d páxinas / entradas + %1$d páxinas / entradas e 1 arquivo restantes %1$d entradas e 1 arquivo restantes %1$d páxinas e 1 arquivo restantes - %1$d páxinas / entradas e 1 arquivo restantes - 1 páxina e 1 arquivo restantes 1 entrada e 1 arquivo restantes - queda 1 páxina e %1$d de %2$d arquivos - queda 1 entrada e %1$d de %2$d arquivos + 1 páxina e 1 arquivo restantes + %1$d páxinas / entradas e %2$d de %3$d arquivos restantes %1$d entradas e %2$d de %3$d arquivos restantes quedan %1$d páxinas e %2$d de %3$d arquivos - %1$d páxinas / entradas e %2$d de %3$d arquivos restantes - %1$d páxinas sen subir + queda 1 entrada e %1$d de %2$d arquivos + queda 1 páxina e %1$d de %2$d arquivos %1$d entradas / páxinas sen subir + %1$d páxinas sen subir 1 páxina sen subir - 1 entrada sen subir %1$d entradas sen subir + 1 entrada sen subir + %1$d entradas / páxinas con %2$d arquivos sen subir + %1$d páxinas %2$d arquivos sen subir 1 páxina con %1$d arquivos sen subir - 1 entrada con %1$d arquivos sen subir %1$d entradas con %2$d arquivos sen subir - %1$d páxinas %2$d arquivos sen subir - %1$d entradas / páxinas con %2$d arquivos sen subir - \@%s - (sen título) - 1 entrada con 1 arquivo sen subir - 1 páxina con 1 arquivo sen subir + 1 entrada con %1$d arquivos sen subir + %1$d entradas / páxinas con 1 arquivo sen subir %1$d páxinas con 1 arquivo sen subir + 1 páxina con 1 arquivo sen subir %1$d entradas con 1 arquivo sen subir - %1$d entradas / páxinas con 1 arquivo sen subir + 1 entrada con 1 arquivo sen subir + (sen título) + \@%s Crear sitio - Gardar - Descartar - Engadir avatar + Toca para continuar. Sitio creado! + A Google levoulle demasiado tempo responder. Pode que teñas que esperar ata que teñas unha conexión a internet máis rápida. Cambiar o nome do usuario - Toca para continuar. - Descartas cambiar de nome de usuario? Teclea para obter máis suxerencias - Ocorreu un erro ao recuperar suxerencias de nomes de usuario. - A Google levoulle demasiado tempo responder. Pode que teñas que esperar ata que teñas unha conexión a internet máis rápida. - Non se suxeriu ningún nome de usuario desde %1$s%2$s%3$s. Por favor, introduce máis letras ou números para obter suxerencias. O teu nome de usuario actual é %1$s%2$s%3$s. Con poucas excepcións, outros só verán o teu nome a amosar, %4$s%5$s%6$s. + Non se suxeriu ningún nome de usuario desde %1$s%2$s%3$s. Por favor, introduce máis letras ou números para obter suxerencias. + Ocorreu un erro ao recuperar suxerencias de nomes de usuario. + Descartas cambiar de nome de usuario? + Descartar + Gardar + Engadir avatar O correo electrónico xa existe en WordPress.com.\nAcceder. - Enviando correo Actualizando conta… - Reintentar + Enviando correo Reintentar - Reverter + Pechar + Houbo algún problema ao enviar o correo electrónico. Podes reintentalo agora ou pechar e intentalo máis tarde. Nome do usuario - Nome a amosar + Sempre podes acceder cunha ligazón máxica coma a que acabas de usar, pero tamén podes configurar un contrasinal se o prefires. Contrasinal (opcional) - Houbo algún problema ao subir o teu avatar. - Houbo algún problema ao enviar o correo electrónico. Podes reintentalo agora ou pechar e intentalo máis tarde. + Nome a amosar + Reintentar + Reverter Houbo algún problema ao actualizar a túa conta. Podes reintentalo ou reverter os teus cambios para continuar. - Sempre podes acceder cunha ligazón máxica coma a que acabas de usar, pero tamén podes configurar un contrasinal se o prefires. - Pechar + Houbo algún problema ao subir o teu avatar. + Necesita actualizarse + Buscar plugins Novo - Gústame - Xestionar - Instalar Populares - Ver todos Ningunha coincidencia - Necesita actualizarse - Engadir sitio novo - Buscar plugins - Erro ao instalar %s + Ver todos + Xestionar Non se puideron buscar plugins + Erro ao instalar %s %s instalado correctamente + Instalar + Gústame + Engadir sitio novo Crea un novo sitio para o teu negocio, revista ou blog personal; ou conecta cunha instalación de WordPress existente. - Carga diferida de imaxes Para obter avisos útiles no teu dispositivo desde o teu sitio WordPress terás que instalar o plugin Jetpack. Gustaríache configurar Jetpack? + Carga diferida de imaxes Instala Jetpack Alternar texto A túa versión de WordPress Require a versión de WordPress Actualizado por última vez - 3 estrelas Versión + 5 estrelas 4 estrelas + 3 estrelas 2 estrelas 1 estrela - 5 estrelas - %s valoracións + Ningún %s descargas + %s valoracións Ler valoracións - Ningún Preguntas frecuentes Que hai de novo - Descrición Instalación + Descrición Axustes Instalado Versión %s instalada - por %s Versión %s + por %s Cambiar foto Non é posible cargar plugins + Páxinas + Xestiona as etiquetas do teu sitio Gardando Borrando - Xestiona as etiquetas do teu sitio Borrar permanentemente a etiqueta \'%s\'? - Páxinas Xa existe unha etiqueta con este nome - Etiqueta - Descrición Engadir nova etiqueta + Descrición + Etiqueta O teu sitio WordPress.com é compatible co uso de páxinas aceleradas para móbiles, unha iniciativa de Google que acelera enormemente a carga das páxinas en dispositivos móbiles Páxinas móbiles aceleradas (AMP) Non se puideron cargar as zonas horarias Aprende máis sobre formatos de data e hora - Personalizador Formato personalizado + Personalizador Entradas por páxina Elixe unha cidade na túa zona horaria Zona horaria @@ -2771,18 +2767,18 @@ Language: gl_ES A versión %s está dispoñible. Actualizacións automáticas Activo + Inactivo Activo Plugins Plugins - Inactivo Abrir enlace nunha nova ventá/pestana Enlace a Ocorreu un erro. Por favor, facilita un código de identificación para continuar. Por favor, volve a teclear o teu contrasinal para continuar. Acceso detido - Acceso en progreso… Por favor, espera mentres se accede. + Acceso en progreso… Toca para continuar. Conectado! Non se puido iniciar o acceso desde Google. @@ -2798,15 +2794,15 @@ Language: gl_ES %d arquivos subidos con éxito , %d subido correctamente 1 arquivo subido - %d arquivos subidos 1 arquivo non subido + %d arquivos subidos %d arquivos non subidos Quitar da entrada Quitar esta imaxe da entrada? Personalizar Detalles do arquivo - Houbo algún problema ao conectar coa conta de Google. \nQuizais probando con outra conta? + Houbo algún problema ao conectar coa conta de Google. Pechar Para seguir con esta conta de Google, por favor, facilita o contrasinal correspondente de WordPress.com. Só se che pedirá unha vez. Ocorreu un erro na rede. Por favor, revisa a túa conexión e inténtao de novo. @@ -2814,46 +2810,46 @@ Language: gl_ES Elixir imaxe destacada Accede a WordPress.com para compartir o contido. Introduce o enderezo do teu sitio WordPress no que queiras compartir o contido. - Sitio desconectado Erro ao desconectar o sitio + Sitio desconectado Desconectar Estás seguro de querer desconectar Jetpack do sitio? «Desconecta de WordPress.com» Podes marcar un enderezo IP (ou series de enderezos) coma «Sempre permitida», evitando que sexa bloqueada por Jetpack. Acéptanse IPv4 e IPv6. Para especificar un rango, introduce un valor inferior e un valor superior separados por un guión. Exemplo: 12.12.12.1–12.12.12.100 Requiere a identificación en dous pasos - Permitir o acceso con WordPress.com Relacionar contas usando o correo electrónico + Permitir o acceso con WordPress.com Acceso con WordPress.com Bloquea intentos de acceso maliciosos Protección contra ataques de forza bruta Enviar avisos instantáneos Enviar avisos por correo electrónico - Seguridade Supervisar o tempo de actividade do teu sitio + Seguridade + Axustes de Jetpack Engadindo a Elixe o sitio - Axustes de Jetpack Engadir á biblioteca de medios Engadir a unha nova entrada IP ou rango de IP non válido Borrando Borrar este vídeo? Eliminar esta imaxe? - Detalles do audio Detalles do documento + Detalles do audio Detalles do vídeo: - Vista previa Detalles da imaxe - Duración + Vista previa Data de actualización + Duración Dimensións do vídeo Sen imaxe - URL - Nome do arquivo Tipo de arquivo + Nome do arquivo + URL Texto alternativo - Luz parpadeante Conectar un sitio + Luz parpadeante Vibración do dispositivo Elixe son Vistas e sons @@ -2868,8 +2864,8 @@ Language: gl_ES Activar os avisos Desactivar os avisos Desactivado - Tamaño máximo de vídeo Activado + Tamaño máximo de vídeo Tamaño máximo de imaxe Houbo un erro ao subir os medios a esta entrada: %s. Houbo un erro ao subir esta entrada: %s. @@ -2881,23 +2877,23 @@ Language: gl_ES Os medios borráronse. Borrámolos desta entrada? Erro ao abrir o navegador web por defecto. Por favor, elixe outra aplicación: Non se puido abrir o enlace - Esta entrada xa non existe Non se puido encontrar a entrada no servidor + Esta entrada xa non existe Cancelouse a subida de medios Houbo un erro ao subir os medios a esta páxina: %s. Houbo un erro ao subir esta páxina: %s. A túa entrada estase subindo Subindo medios… - Entrada programada Páxina programada + Entrada programada Reintentar Entrada á espera Subindo «%s» Perdeuse a conexión co servidor - O meu sitio Os meus sitios - Por favor, introduce un código de verificación + O meu sitio Non se puido detectar un cliente de correo electrónico + Por favor, introduce un código de verificación Por favor, introduce un nome de usuario Accede a WordPress.com para acceder á entrada. Erro ao engadir o sitio. Código de erro: %s @@ -2916,15 +2912,15 @@ Language: gl_ES Solicitando un código de verificación por SMS. Envíame un código en texto no seu lugar Case o temos! Por favor, introduce o código de verificación para WordPress.com desde a túa aplicación Authenticator. - Seguinte Abrir correo electrónico + Seguinte Accede a WordPress.com usando un enderezo de correo electrónico para xestionar todos os teus sitios WordPress. Foto de perfil Resposta inesperada do servidor Non se pode detener a subida porque xa finalizou + Título Refacer Desfacer - Título Desculpas! Esta característica aínda non está implementada :( Medios demasiado pequenos para amosar Advertencia: non todos os elementos arrastrados son compatibles! @@ -2932,18 +2928,18 @@ Language: gl_ES Ocorreu un erro ao arrastrar texto Non está permitido arrastrar imaxes no modo HTML Comparte a túa historia aquí… - Borrador Privada + Borrador Pendente de revisión Publicar Agora Só os que teñan este contrasinal poden ver esta entrada Os extractos son resumos opcionais do contido feitos a man. O slug é a versión amigable da URL do título da entrada. - Slug + Formato de entrada Etiquetas + Slug Extracto - Formato de entrada Non definido Máis opcións Categorías e etiquetas @@ -2951,27 +2947,27 @@ Language: gl_ES Nivel superior Categoría superior (opcional): Non tes ningún audio - No tes ningún vídeo Non tes ningún documento + No tes ningún vídeo No tes ningunha imaxe O servidor tarda demasiado en responder Arquivo demasiado grande para subir a este sitio O arquivo supera o tamaño máximo de subida deste sitio Vídeo demasiado grande para subir A imaxe é demasiado grande para subila. Trata de cambiar a optimización de imaxes nos axustes da aplicación - Todos Audio Vídeos - Imaxes Documentos + Imaxes + Todos %1$s denegou o acceso aos teus arquivos de medios. Para solucionar, isto modifica os teus permisos e activa %2$s. Ver comentarios Calidade dos vídeos. Valores máis altos implican vídeos de mellor calidade. - Activa o redimensionado e a compresión de vídeos Redimensiona os vídeos nas entradas a este tamaño + Activa o redimensionado e a compresión de vídeos Optimizar vídeos - Calidade do vídeo Borrador subido + Calidade do vídeo Cámara Almacenamento Editar permisos @@ -2981,41 +2977,41 @@ Language: gl_ES Cambia o texto da etiqueta dos botóns de compartir. Este texto non aparecerá ata que engadas polo menos un botón de compartir. Conectando conta A conexión con %s non se puido establecer debido a que non se seleccionou ningunha conta. - Gústame - Twitter Conectado + Twitter + Gústame Permite que ti e os teus lectores lle dean gústame a todos os comentarios Botóns Editar os botóns «Máis» Un botón «Máis» contén un despregable que mostra botóns de compartir Elixe que botóns se mostrarán debaixo das túas entradas - Gústame ao comentario Usuario de Twitter - Etiqueta + Gústame ao comentario Estilo do botón + Etiqueta Botóns de compartir Amosar botón de gústame - Reblog e gústame Amosar botón de reblog - Só texto + Reblog e gústame Botóns oficiais + Só texto Só icona Icona e texto Elixe a conta á que queres autorizar. Dáte conta de que as túas entradas compartiranse automaticamente na conta seleccionada. Conectando %s Desconectar de %s? Conectar con outra conta - Conectar Reconectar Desconectar + Conectar Conéctate para compartir automaticamente as entradas do teu blog en %s Contas conectadas Conecta cos teus servizos de medios sociais favoritos para compartir automaticamente as novas entradas cos teus amigos. Avisos. Xestiona os teus avisos. Lector. Segue contido doutros sitios. O meu sitio. Ve o teu sitio e xestiónao, estatísticas incluídas. - Agora non Social + Agora non Erro na subida. Trata de cambiar a optimización de imaxes nos axustes da túa aplicación Gardando medios neste dispositivo Non foi posible gardar os medios @@ -3026,12 +3022,12 @@ Language: gl_ES Toca e mantén para elixir varios comentarios Elixe un vídeo do dispositivo Elixe unha foto do dispositivo - Engadir como galería Medios de WordPress + Engadir como galería Engadir individualmente - 1 columna - %d columnas Engadir varias fotos + %d columnas + 1 columna Volver a enviar correo electrónico Enviamos un correo electrónico a %s cando te rexistraches. Por favor, abre a mensaxe e fai clic no botón azul para activar a publicación. Enviámosche un correo electrónico cando te rexistraches. Por favor, abre a mensaxe e fai clic no botón azul para activar a publicación. @@ -3039,8 +3035,8 @@ Language: gl_ES Erro ao enviar o correo electrónico de verificación. Xa o verificaches? Enviado o correo electrónico de verificación, revisa a túa bandexa de entrada Gardando a entrada coma borrador - Tomar foto Tomar vídeo + Tomar foto Ten coidado! Unha vez se borre un sitio non se pode recuperar. Por favor, pénsao ben antes de proceder. Todas as túas entradas, imaxes e datos borraranse. E o enderezo deste sitio (%s) perderase. Borrar sitio? @@ -3124,17 +3120,17 @@ Language: gl_ES Comentario aprobado! Gústame agora - Seguidor Lector + Seguidor Sen conexión, non se pode gardar o perfil. - Ningún - Esquerda Dereita + Esquerda + Ningún Seleccionado %1$d Non foi posible obter os usuarios do sitio + Seguidor por correo-e Seguidor Á procura dos usuarios… - Seguidor por correo-e Lectores Seguidores por correo-e Seguidores @@ -3148,23 +3144,23 @@ Language: gl_ES Non foi posible obter os seguidores do sitio por correo electrónico Non foi posible obter os seguidores do sitio Fallou a carga dalgúns ficheiros. Nesta situación non podes cambiar a modo HTML\nQueres borrar todas as cargas que fallaron e continuar? - Editor visual Miniatura - Cambios gardados - Lenda - Texto alternativo - Ligar a + Editor visual Largo + Ligar a + Texto alternativo + Lenda + Cambios gardados Descartar os cambios sen gardar? Suspender a carga? Houbo un erro na inserción do ficheiro Estanse a cargar os ficheiros. Por favor, espera a que o proceso termine. Non se poden inserir ficheiros directamente no modo HTML. Hai que volver ao modo visual. Cargando a galería… - Enviouse a invitación, pero houbo algún erro. + Pulsa para tentalo outra vez! Invitación enviada con éxito %1$s: %2$s - Pulsa para tentalo outra vez! + Enviouse a invitación, pero houbo algún erro. Houbo un erro mentres se enviaba a invitación. Non se pode enviar: Hai nomes de usuario ou enderezos de correo non válidos. Fallou o envío: un nome de usuario ou un enderezo non é válido. @@ -3172,8 +3168,8 @@ Language: gl_ES Mensaxe personalizada Invitar Nomes de usuario ou enderezos de correo electrónico - Externo Invitar xente + Externo Limpar o historial de busca Limpar o historial de busca? Non se atoparon artigos para %s na túa lingua @@ -3181,33 +3177,33 @@ Language: gl_ES Artigos relacionados Os enlaces están inhabilitados na vista previa Enviar - Se eliminas a %1$s, o usuario non poderá acceder máis a este sitio, aínda que todos os contidos creados por %1$s permanecerán nel.\n\nQueres aínda así eliminar a este usuario? %1$s foi eliminado + Se eliminas a %1$s, o usuario non poderá acceder máis a este sitio, aínda que todos os contidos creados por %1$s permanecerán nel.\n\nQueres aínda así eliminar a este usuario? Eliminar a %1$s - Os sitios desta lista non publicaron nada ultimamente - Xente Rol + Xente + Os sitios desta lista non publicaron nada ultimamente Non foi posible eliminar o usuario - Non foi posible obter os lectores do sitio Non foi posible actualizar o rol do usuario + Non foi posible obter os lectores do sitio Houbo un erro ao actualizar o teu Gravatar - Erro na localización da imaxe recortada Erro ao recargar o teu Gravatar + Erro na localización da imaxe recortada Erro no recorte da imaxe Verificando o enderezo de correo electrónico Non dispoñible neste momento. Introduce o teu contrasinal.. Conectando Móstrase publicamente no teus comentarios. Fai ou escolle unha foto - Os teus artigos, páxinas e configuración enviaránseche a %s. - Plan Plans + Plan + Os teus artigos, páxinas e configuración enviaránseche a %s. Exportar os contidos - Exportando os contidos… Mensaxe de exportación enviada - Tes melloras Premium no teu sitio. Cancela as melloras antes de eliminalo. - Mostrar compras + Exportando os contidos… Comprobando as compras + Mostrar compras + Tes melloras Premium no teu sitio. Cancela as melloras antes de eliminalo. Melloras Premium Algo foi mal. Non foi posible solicitar as compras. Eliminando o sitio… @@ -3216,15 +3212,15 @@ Language: gl_ES Dominio principal Houbo un erro ao eliminar o sitio. Por favor, contacta co soporte para obter asistencia. Erro na eliminación do sitio - Escribe %1$s na caixa de embaixo para confirmar. Nese momento o teu sitio desaparecerá para sempre. Exportar os contidos + Escribe %1$s na caixa de embaixo para confirmar. Nese momento o teu sitio desaparecerá para sempre. Confirmar a eliminación do sitio Contactar co soporte Se queres un sitio pero non queres preservar os contidos que tes agora, o noso soporte pode eliminar artigos, páxinas, ficheiros e comentarios por ti.\n \nIsto manterá activo o teu sitio e a súa URL, e darache a oportunidade de comezar a crear contidos desde cero. Contacta connosco para solicitar unha limpeza total do teu sitio. - Borrar todo e comezar desde cero Imos che axudar - Configuración da aplicación + Borrar todo e comezar desde cero Comezar desde cero + Configuración da aplicación Eliminar os contidos que non foi posible cargar Avanzado Non hai comentarios no lixo @@ -3232,34 +3228,34 @@ Language: gl_ES Non hai comentarios aprobados Omitir Non foi posible conectar. Os métodos XML-RPC requiridos non están no servidor. - Estado - Vídeo Centro - Galería - Imaxe - Cita + Vídeo + Estado Estándar - Chat + Cita Ligazón + Imaxe + Galería + Chat + Audio Aparte Información sobre os cursos e eventos de WordPress.com (en liña ou presenciais) - Audio Oportunidades para participar nas investigacións e enquisas de WordPress.com. Trucos para sacar o mellor partido de WordPress.com Comunidade - Respostas aos meus comentarios - Propostas Investigación - Logros do sitio + Propostas + Respostas aos meus comentarios Mencións do usuario + Logros do sitio Seguimentos do sitio Gústame nos meus artigos Gústame nos meus comentarios Comentarios no meu sitio %d elementos 1 elemento - Comentarios de usuarios coñecidos Todos os usuarios + Comentarios de usuarios coñecidos Ningún comentario %d comentarios por páxina 1 comentario por páxina @@ -3269,11 +3265,11 @@ Language: gl_ES Aprobar automaticamente os comentarios de quen sexa. Aprobar automaticamente se o usuario ten un comentario aprobado previamente Solicitar aprobación manual para calquera comentario. - 1 día %d días + 1 día + Enderezo web Sitio principal Preme no enlace de verificación na mensaxe enviada a %1$s para confirmar o teu novo enderezo - Enderezo web Estanse a cargar os ficheiros. Por favor, espera a que o proceso termine. Non foi posible actualizar os comentarios neste momento - mostrando comentarios antigos Poñer como imaxe destacada @@ -3282,13 +3278,13 @@ Language: gl_ES Eliminar definitivamente estes comentarios? Eliminar definitivamente este comentario? Eliminar - Comentario eliminado Recuperar + Comentario eliminado Non hai comentarios spam - Non foi posible cargar a páxina Todo - Idioma da interface + Non foi posible cargar a páxina Off + Idioma da interface Acerca da aplicación Non foi posible gardar a configuración da conta Non foi posible acceder á configuración da conta @@ -3296,9 +3292,9 @@ Language: gl_ES Código de idioma no aceptado Permitir a xerarquización dos comentarios en fíos Fíos até - Borrar - Busca Desactivado + Busca + Borrar Tamaño orixinal O sitio só é visible para ti e para os usuarios autorizados O sitio é visible para calquera, pero pídese aos motores de busca que non o indexen. @@ -3307,9 +3303,9 @@ Language: gl_ES Acerca de mi Se non se define, o nome mostrado por defecto será o nome de usuario, Nome público - O meu perfil - Nome Apelido + Nome + O meu perfil Imaxe de vista previa do artigo relacionado Non foi posible gardar a información do sitio Non foi posible acceder á información do sitio @@ -3364,8 +3360,8 @@ Language: gl_ES %d niveis Privado Oculto - Eliminar o sitio Público + Eliminar o sitio Pendentes de moderación Enlaces nos comentarios Aprobar automaticamente @@ -3380,22 +3376,22 @@ Language: gl_ES Formato predeterminado Categoría predeterminada Enderezo - Título do sitio Subtítulo + Título do sitio Prederterminado para os artigos novos - Conta Escrita - Os máis recentes primeiro + Conta Xeral - Conversa - Privacidade - Artigos relacionados - Comentarios + Os máis recentes primeiro Os máis antigos primeiro Pecar despois de + Comentarios + Artigos relacionados + Privacidade + Conversa Non tes permiso para cargar ficheiros neste sitio - Nunca Descoñecido + Nunca O artigo xa non existe Non tes permiso para ver este artigo Non foi posible acceder a este artigo @@ -3407,22 +3403,22 @@ Language: gl_ES Algo foi mal. Non foi posible activar o tema. por %1$s Grazas por escoller %1$s - Ver - Detalles - Soporte - FEITO XESTIONAR O SITIO + FEITO + Soporte + Detalles + Ver Probar e personalizar Activar - Tema actual - Personalizar - Detalles - Soporte Activo - Artigo publicado - Páxina publicada - Artigo actualizado + Soporte + Detalles + Personalizar + Tema actual Páxina actualizada + Artigo actualizado + Páxina publicada + Artigo publicado Sentímolo, non se atoparon temas. Cargar máis artigos Non hai sitios que coincidan con \'%s\' @@ -3442,8 +3438,8 @@ Language: gl_ES Publicado orixinalmente por %s Publicado orixinalmente por %1$s en %2$s %s gústame - Gústame 1 gústame + Gústame %,d seguidores Editar temas e sitios Artigo da Guía de Lectura @@ -3455,18 +3451,18 @@ Language: gl_ES Non foi posible cargar a configuración das notificacións Gústame ao comentario Notificacións da aplicación - Lapela de notificacións Correo electrónico + Lapela de notificacións Enviaremos sempre mensaxes importantes relativos á túa conta, e terás tamén algúns extras de utilidade. Resumo do último artigo Sen conexión Artigo botado ao lixo + Lixo Estatísticas Vista previa Ver - Editar Publicar - Lixo + Editar Non tes autorización para acceder a este blogue Non foi posible atopar este blogue Desfacer @@ -3477,254 +3473,254 @@ Language: gl_ES Entradas, visitas e lectores desde o comezo. Información Desconectarse de WordPress.com - Entrar/Saír Conectarse a WordPress.com + Entrar/Saír Configuración da conta \"%s\" non foi ocultado porque é o sitio actual Crear un sitio en WordPress.com + Engadir un sitio autoaloxado Engadir sitio Mostrar/ocultar sitios - Engadir un sitio autoaloxado - Ver o sitio Escoller sitio - Cambiar de sitio + Ver o sitio Ir ao Panel - Publicar - Aparencia + Cambiar de sitio Axustes do sitio Entradas - Pulsa para velos + Publicar + Aparencia Configuración - Mostrar - Ocultar - Seleccionar todo + Pulsa para velos Desmarcar todo - Idioma - Código de verificación - Código de verificación non válido + Seleccionar todo + Ocultar + Mostrar Inicia sesión de novo para continuar + Código de verificación non válido + Código de verificación + Idioma + Fallou a procura de artigos + Non foi posible abrir a notificación Termos de busca descoñecidos - Autores Termos de busca + Autores Á procura das páxinas… Á procura dos artigos… Á procura dos ficheiros… - Non foi posible abrir a notificación - Fallou a procura de artigos - Cargando - Artigos novos + Os rexistros da aplicación foron copiados no portapapeis Este blogue está baleiro + Artigos novos Houbo un erro ao copiar o texto no portapapeis - Os rexistros da aplicación foron copiados no portapapeis + Cargando + %1$d anos + Un ano + %1$d meses + Un mes + %1$d días + Un día + %1$d horas hai unha hora + %1$d minutos hai un minuto hai uns segundos - Artigos e páxinas - Vídeos Seguidores + Vídeos + Artigos e páxinas Países - Anos - Visitas + Gústame Lectores + Visitas + Anos Á procura dos temas… - %1$d meses - Un ano - %1$d anos - Un mes - %1$d minutos - %1$d horas - Un día - %1$d días - Gústame Detalles %d seleccionados + Ves as FAQ Sen comentarios aínda + Non hai entradas con esta temática + Gústame Ver o artigo orixinal Os comentarios están pechados %1$d de %2$d Non se pode publicar un artigo baleiro - Non tes permiso para ver ou editar páxinas Non tes permiso para ver ou editar artigos - Con máis dun mes de antigüidade + Non tes permiso para ver ou editar páxinas Máis - Con máis de dous días de antigüidade + Con máis dun mes de antigüidade Con máis dunha semana de antigüidade + Con máis de dous días de antigüidade + Axuda e soporte + Gustou Comentario Comentario no lixo - Aínda sen artigos. Por que non crear un? Resposta a %s - Gústame - Gustou - Ves as FAQ + Aínda sen artigos. Por que non crear un? Saíndo… - Non hai entradas con esta temática - Axuda e soporte - Bloquear este blogue + Non foi posible realizar esta acción Non foi posible bloquear este blogue Os artigos deste blogue non se mostrarán máis - Non foi posible realizar esta acción - Actualizar + Bloquear este blogue Programar - Sitios que sigo - Non se pode mostrar este blogue - Xa segues este blogue - Non foi posible seguir este blogue - Non foi posible deixar de seguir este blogue + Actualizar Sen blogues recomendados - Blogue da Guía de Lectura + Non foi posible deixar de seguir este blogue + Non foi posible seguir este blogue + Xa segues este blogue + Non se pode mostrar este blogue Blogue en seguimento - Temas seguidos - Introduce unha URL ou tema para seguir %s seguidores - Axuda - Certificado SSL non válido + Introduce unha URL ou tema para seguir + Sitios que sigo + Temas seguidos + Blogue da Guía de Lectura Se habitualmente conectas a este sitio sen problema, este erro pode significar que alguén esta¡á tentando suplantarche no sitio, así que que non deberías continuar. Queres confiar no certificado aínda así? - Non hai rede dispoñible - Houbo un erro mentres se accedía a este blogue - Non é spam - Categoría engadida con éxito - O campo de nome de categoría é obrigatorio - Sen notificacións - Houbo un erro - Introduce un enderezo de correo electrónico válido + Certificado SSL non válido + Axuda O nome de usuario ou o contrasinal introducido é incorrecto - Non foi posible obter o elemento multimedia - Non foi posible actualizar os artigos neste momento - Non foi posible actualizar as páxinas neste momento - Non foi posible cargar o comentario - Non foi posible actualizar os comentarios neste momento + Introduce un enderezo de correo electrónico válido O enderezo de correo non é válido - Houbo un erro durante a moderación - Houbo un erro durante a edición do comentario Erro na descarga da imaxe - Fallou a procura de temas - Fallou a adición da categoría - Para cargar ficheiros fai é necesario ter unha tarxeta SD montada + Non foi posible cargar o comentario + Houbo un erro durante a edición do comentario + Houbo un erro durante a moderación + Houbo un erro + Non foi posible actualizar os comentarios neste momento + Non foi posible actualizar as páxinas neste momento + Non foi posible actualizar os artigos neste momento Houbo un erro mentres se eliminaba a entrada - Sen notificacións … de momento. - Artigo novo - Ficheiro novo - Cambios locais - Configuración da imaxe + Sen notificacións + Para cargar ficheiros fai é necesario ter unha tarxeta SD montada + O campo de nome de categoría é obrigatorio + Categoría engadida con éxito + Fallou a adición da categoría + Non é spam + Fallou a procura de temas + Houbo un erro mentres se accedía a este blogue + Non foi posible obter o elemento multimedia + Non hai rede dispoñible + Non se puido eliminar este tema + Non se puido engadir este tema + Rexistro da aplicación + Houbo un erro mentres se creaba a base de datos da aplicación. Proba a reinstalar a aplicación. + Este blogue non se pode cargar porque está oculto. Habilítao de novo na configuración e proba outra vez. + Non se poden actualizar os medios neste momento Blogue feito con WordPress - Engadir categoría nova - Nome da categoría - Non foi posible crear o ficheiro temporal para a carga. Asegúrate de que haxa espazo libre suficiente no dispositivo. - Comproba se a URL introducida é válida + Configuración da imaxe + Cambios locais + Ficheiro novo + Artigo novo + Sen notificacións … de momento. Necesítase autorización - Gardando os cambios - Borrar o sitio + Comproba se a URL introducida é válida + Non foi posible crear o ficheiro temporal para a carga. Asegúrate de que haxa espazo libre suficiente no dispositivo. + Nome da categoría + Engadir categoría nova Ver no navegador - Seleccionar categorías - Erro de conexión - Grella de miniaturas - Non tes permiso para ver a Biblioteca Multimedia - Non se poden eliminar algún ficheiros neste momento. Téntao de novo máis tarde. - Texto do enlace (opcional) - Crear un enlace - Configuración da páxina - Borrador local - Aliñamento horizontal - Configuración do artigo - Eliminar o artigo - Eliminar a páxina - Aprobado - Pendente - Spam - No lixo - Editar o comentario - Aprobar - Rexeitar - Spam - Botar ao lixo - Botar ao lixo? + Borrar o sitio + Gardando os cambios Lixo - Rexistro da aplicación - Este blogue non se pode cargar porque está oculto. Habilítao de novo na configuración e proba outra vez. - Houbo un erro mentres se creaba a base de datos da aplicación. Proba a reinstalar a aplicación. - Houbo un erro mentres se cargaba o artigo. Actualiza os artigos e téntao de novo. - Saber máis + Botar ao lixo? + Botar ao lixo + Spam + Rexeitar + Aprobar + Editar o comentario + No lixo + Spam + Pendente + Aprobado + Eliminar a páxina + Eliminar o artigo + Configuración do artigo Non foi posible atopar o ficheiro para cargar. Terá sido eliminado ou movido? - Non se poden actualizar os medios neste momento + Aliñamento horizontal + Borrador local + Configuración da páxina + Crear un enlace + Texto do enlace (opcional) + Non se poden eliminar algún ficheiros neste momento. Téntao de novo máis tarde. + Non tes permiso para ver a Biblioteca Multimedia + Grella de miniaturas + Saber máis + Houbo un erro mentres se cargaba o artigo. Actualiza os artigos e téntao de novo. Ocorreu un erro ao acceder a este plugin - Non se puido engadir este tema - Non se puido eliminar este tema + Erro de conexión + Seleccionar categorías Enlace para compartir Á procura de artigos… - Comentario sinalado como spam - Non podes compartir en WordPress se non tes algún blogue visible A ti e a outros %,d gustoulles isto A %,d persoas gustoulles isto + Non podes compartir en WordPress se non tes algún blogue visible + Comentario sinalado como spam Comentario sen aprobar - Escoller foto - Escoller vídeo - A ti e a un máis gustoulles isto Non foi posible acceder a este artigo - (Sen título) - Compartir - Seguir - Seguindo - Responder ao comentario - %s engadido - %s eliminado + A ti e a un máis gustoulles isto + Escoller vídeo + Escoller foto + Rexistro + No foi posible abrir %s + Non foi posible mostrar a imaxe + Non foi posible compartir + Non é un tema válido + Xa segues este tema Non foi posible enviar o comentario - Esta lista está baleira - A unha persoa gustoulle isto Gústache isto - Non foi posible compartir - Non foi posible mostrar a imaxe - No foi posible abrir %s - Sen comentarios aínda + A unha persoa gustoulle isto + %s eliminado + %s engadido + Responder ao comentario + Seguindo + Seguir + Compartir Reblog - Rexistro - Xa segues este tema - Non é un tema válido - Etiquetas e categorías - Referencias - Hoxe - Onte - Días - Semanas + (Sen título) + Sen comentarios aínda + Esta lista está baleira Meses - Activar - Compartir + Semanas + Días + Onte + Hoxe + Referencias + Etiquetas e categorías + Clics Estatísticas - Cadrados - Mosaico - Círculos - Pase de diapositivas - Título - Lenda + Compartir + Activar + Fallou a actualización Descrición + Lenda + Título + Pase de diapositivas + Círculos + Mosaico + Cadrados Temas - Clics - Fallou a actualización Descartar Xestionar - %d notificacións novas e %d máis. - Resposta publicada + %d notificacións novas Segue + Resposta publicada Entrar Cargando… - usuario HTTP contrasinal HTTP + usuario HTTP Houbo un erro mentres se cargaba o ficheiro Nome de usuario ou contrasinal incorrecto - Contrasinal - Nome de usuario Iniciar sesión + Nome de usuario + Contrasinal Guía de Lectura - Páxinas - Lenda (opcional) + Incluír una imaxe no contido do artigo + Usar como imaxe destacada Largo + Lenda (opcional) + Páxinas Artigos Anónimo - Usar como imaxe destacada - Incluír una imaxe no contido do artigo Non hai rede dispoñible - Vale feito + Vale URL Cargando… Aliñación @@ -3742,22 +3738,22 @@ Language: gl_ES Tarxeta SD requirida Multimedia Categoría actualizada correctamente. - Borrar Aprobar - Ningún + Borrar Actualizando a categoría que fallou - Si - Non - Opcións de notificación + Ningún + Publicar agora Responder + en Vista previa + Erro na actualización da categoría Erro - Cancelar - Gardar + Non + Si + Opcións de notificación Engadir - en - Erro na actualización da categoría - Publicar agora + Gardar + Cancelar Unha vez Dúas veces diff --git a/WordPress/src/main/res/values-he/strings.xml b/WordPress/src/main/res/values-he/strings.xml index b83d4237be00..e8ccbf082322 100644 --- a/WordPress/src/main/res/values-he/strings.xml +++ b/WordPress/src/main/res/values-he/strings.xml @@ -1,6 +1,6 @@ + @color/black + @color/white diff --git a/WordPress/src/main/res/values-nl/strings.xml b/WordPress/src/main/res/values-nl/strings.xml index 4a2ae1469679..483b2aa9b458 100644 --- a/WordPress/src/main/res/values-nl/strings.xml +++ b/WordPress/src/main/res/values-nl/strings.xml @@ -1,6 +1,6 @@ + + + + + + @color/material_on_surface_emphasis_high_type + @color/white + @color/transparent + @color/black diff --git a/WordPress/src/main/res/values/dimens.xml b/WordPress/src/main/res/values/dimens.xml index 15e6f1d173d0..00bf72e4437b 100644 --- a/WordPress/src/main/res/values/dimens.xml +++ b/WordPress/src/main/res/values/dimens.xml @@ -80,12 +80,6 @@ 60dp 108dp - 0dp - 44dp - 92dp - - 16dp - 0dp @dimen/content_margin_none @dimen/content_margin_none @@ -218,14 +212,12 @@ 150dp - 16dp - 14dp - 48dp - 30dp + 440dp + 3.0 1.5 @@ -241,7 +233,6 @@ 12dp 8dp - 44dp 2dp 1dp @@ -252,6 +243,10 @@ 1dp 36dp + 5dp + 1dp + 36dp + 32dp 8dp @@ -283,8 +278,10 @@ 12dp 18dp 12dp + 28dp 16dp 4dp + 4dp 3dp 5dp diff --git a/WordPress/src/main/res/values/reader_styles.xml b/WordPress/src/main/res/values/reader_styles.xml index c357558c0571..f9aea6b3e587 100644 --- a/WordPress/src/main/res/values/reader_styles.xml +++ b/WordPress/src/main/res/values/reader_styles.xml @@ -242,9 +242,36 @@ - + + + + - - - + + + + - - + +