diff --git a/docs/tasks/internationalization.md b/docs/tasks/internationalization.md index 5e7c3f4..d490cf4 100644 --- a/docs/tasks/internationalization.md +++ b/docs/tasks/internationalization.md @@ -19,21 +19,16 @@ ## M2 - Remaining Feature Surfaces -- [ ] Localize feed cards, post menus, post actions, saved posts, and trending -- [ ] Localize compose flow, media alt text editors, draft/schedule states, and validation -- [ ] Localize profile screens, profile actions, reports, follows, lists, and starter packs -- [ ] Localize messages, notifications, alerts, and account switching sheets -- [ ] Localize moderation settings/detail screens and logs/devtools user-facing labels +- [x] Localize feed cards, post menus, post actions, saved posts, and trending +- [x] Localize compose flow, media alt text editors, draft/schedule states, and validation +- [x] Localize profile screens, profile actions, reports, follows, lists, and starter packs +- [x] Localize messages, notifications, alerts, and account switching sheets +- [x] Localize moderation settings/detail screens and logs/devtools user-facing labels -## M3 - Locale QA and Secondary-Locale Strategy +## M3 - User-Facing Language Selection - [ ] Add a pseudo-locale or generated QA locale for layout stress testing -- [ ] Audit hard-coded visible strings after M2 -- [ ] Add locale-aware golden/widget coverage for dense layouts - [ ] Decide first real non-English locale and translation ownership - -## M4 - User-Facing Language Selection - - [ ] Add persisted language preference only after multiple real locales exist - [ ] Add settings UI for system/default language and supported overrides - [ ] Add tests for locale override persistence and app rebuild behavior diff --git a/lib/core/l10n/app_localizations.dart b/lib/core/l10n/app_localizations.dart index 81fc8a3..47e5e17 100644 --- a/lib/core/l10n/app_localizations.dart +++ b/lib/core/l10n/app_localizations.dart @@ -109,6 +109,12 @@ abstract class AppLocalizations { /// **'Cancel'** String get buttonCancel; + /// Tooltip for composing a post + /// + /// In en, this message translates to: + /// **'Compose'** + String get buttonCompose; + /// Button label to clear local cache /// /// In en, this message translates to: @@ -121,6 +127,36 @@ abstract class AppLocalizations { /// **'Continue'** String get buttonContinue; + /// Button label to clear locally stored items + /// + /// In en, this message translates to: + /// **'Clear Local'** + String get buttonClearLocal; + + /// Delete button label + /// + /// In en, this message translates to: + /// **'Delete'** + String get buttonDelete; + + /// Discard button label + /// + /// In en, this message translates to: + /// **'Discard'** + String get buttonDiscard; + + /// Button label to load additional quote posts + /// + /// In en, this message translates to: + /// **'Load more quotes'** + String get buttonLoadMoreQuotes; + + /// Button label to load additional repost users + /// + /// In en, this message translates to: + /// **'Load more reposts'** + String get buttonLoadMoreReposts; + /// Remove button label /// /// In en, this message translates to: @@ -151,6 +187,18 @@ abstract class AppLocalizations { /// **'Open'** String get buttonOpen; + /// OK button label + /// + /// In en, this message translates to: + /// **'OK'** + String get buttonOk; + + /// Button label to publish a post + /// + /// In en, this message translates to: + /// **'Post'** + String get buttonPost; + /// Button label to clear local sign-in data /// /// In en, this message translates to: @@ -163,6 +211,18 @@ abstract class AppLocalizations { /// **'Retry'** String get buttonRetry; + /// Save button label + /// + /// In en, this message translates to: + /// **'Save'** + String get buttonSave; + + /// Button label to save post edits + /// + /// In en, this message translates to: + /// **'Save Changes'** + String get buttonSaveChanges; + /// Sign in button label /// /// In en, this message translates to: @@ -175,6 +235,12 @@ abstract class AppLocalizations { /// **'Show content'** String get buttonShowContent; + /// Share button or menu label + /// + /// In en, this message translates to: + /// **'Share'** + String get buttonShare; + /// Try again button label /// /// In en, this message translates to: @@ -193,6 +259,18 @@ abstract class AppLocalizations { /// **'None'** String get commonNone; + /// Lowercase relative time label for the current moment + /// + /// In en, this message translates to: + /// **'now'** + String get commonNow; + + /// Relative time label for the current moment + /// + /// In en, this message translates to: + /// **'Just now'** + String get commonJustNow; + /// Label for a health check that has not run /// /// In en, this message translates to: @@ -205,6 +283,12 @@ abstract class AppLocalizations { /// **'Off'** String get commonOff; + /// Lowercase fallback for an unknown value + /// + /// In en, this message translates to: + /// **'unknown'** + String get commonUnknown; + /// Confirmation dialog body before clearing local cache /// /// In en, this message translates to: @@ -217,6 +301,72 @@ abstract class AppLocalizations { /// **'Clear cache?'** String get dialogClearCacheTitle; + /// Confirmation dialog body before clearing local bookmarks + /// + /// In en, this message translates to: + /// **'This removes only local bookmarks from this device. Bluesky cloud bookmarks will not be deleted.'** + String get dialogClearLocalBookmarksContent; + + /// Confirmation dialog title before clearing local bookmarks + /// + /// In en, this message translates to: + /// **'Clear local bookmarks?'** + String get dialogClearLocalBookmarksTitle; + + /// Confirmation dialog body before deleting a post + /// + /// In en, this message translates to: + /// **'This action cannot be undone.'** + String get dialogDeletePostContent; + + /// Confirmation dialog title before deleting a post + /// + /// In en, this message translates to: + /// **'Delete Post?'** + String get dialogDeletePostTitle; + + /// Confirmation dialog title before deleting a draft + /// + /// In en, this message translates to: + /// **'Delete Draft?'** + String get dialogDeleteDraftTitle; + + /// Confirmation dialog body before leaving unsaved post edits + /// + /// In en, this message translates to: + /// **'You have unsaved edits. Discard them and leave?'** + String get dialogDiscardChangesContent; + + /// Confirmation dialog title before leaving unsaved post edits + /// + /// In en, this message translates to: + /// **'Discard Changes?'** + String get dialogDiscardChangesTitle; + + /// Information dialog body explaining how post editing is implemented + /// + /// In en, this message translates to: + /// **'Lazurite saves edits by deleting and recreating the post record with the same URI. During re-indexing, ranking, counters, and search visibility can shift, and updates may take time to appear everywhere.'** + String get dialogEditAlgorithmContent; + + /// Information dialog title explaining how post editing works + /// + /// In en, this message translates to: + /// **'How Post Editing Works'** + String get dialogEditAlgorithmTitle; + + /// Confirmation dialog body before leaving compose with unsaved content + /// + /// In en, this message translates to: + /// **'You have unsaved content. Would you like to save it as a draft?'** + String get dialogSaveDraftContent; + + /// Confirmation dialog title before leaving compose with unsaved content + /// + /// In en, this message translates to: + /// **'Save Draft?'** + String get dialogSaveDraftTitle; + /// Confirmation dialog body before removing a saved account from this device /// /// In en, this message translates to: @@ -271,6 +421,48 @@ abstract class AppLocalizations { /// **'Something went wrong'** String get errorGenericTitle; + /// Error title shown when bookmarks cannot load + /// + /// In en, this message translates to: + /// **'Failed to load bookmarks'** + String get errorFailedToLoadBookmarks; + + /// Error title shown when liked posts cannot load + /// + /// In en, this message translates to: + /// **'Failed to load liked posts'** + String get errorFailedToLoadLikedPosts; + + /// Error message shown when liked posts cannot load + /// + /// In en, this message translates to: + /// **'Failed to load liked posts: {error}'** + String errorFailedToLoadLikedPostsDetails(Object error); + + /// Warning message shown when refreshing liked posts fails + /// + /// In en, this message translates to: + /// **'Failed to refresh liked posts: {error}'** + String errorFailedToRefreshLikedPosts(Object error); + + /// Error title shown when trending content cannot load + /// + /// In en, this message translates to: + /// **'Failed to load trending'** + String get errorFailedToLoadTrending; + + /// Error message shown when trending topics cannot load + /// + /// In en, this message translates to: + /// **'Failed to load trending topics: {error}'** + String errorFailedToLoadTrendingTopics(Object error); + + /// Fallback message when an error has no details + /// + /// In en, this message translates to: + /// **'Unknown error'** + String get errorUnknown; + /// Snackbar message when a saved account cannot be removed /// /// In en, this message translates to: @@ -307,6 +499,144 @@ abstract class AppLocalizations { /// **'Depth {depth}'** String formatDepth(int depth); + /// Fallback liked-post card subtitle showing when a post was liked + /// + /// In en, this message translates to: + /// **'Liked on {date}'** + String formatLikedOn(String date); + + /// Interaction sheet tab label showing like count + /// + /// In en, this message translates to: + /// **'{count, plural, =1{1 Like} other{{count} Likes}}'** + String formatLikesCount(int count); + + /// Message explaining that a post action requires reconnecting + /// + /// In en, this message translates to: + /// **'You are offline. Reconnect to {action}.'** + String formatOfflineReconnectAction(String action); + + /// Post card reply context label showing the parent post author + /// + /// In en, this message translates to: + /// **'Replying to @{handle}'** + String formatReplyingToHandle(String handle); + + /// Interaction sheet tab label showing repost count + /// + /// In en, this message translates to: + /// **'{count, plural, =1{1 Repost} other{{count} Reposts}}'** + String formatRepostsCount(int count); + + /// Fallback saved-post card subtitle showing when a post was saved + /// + /// In en, this message translates to: + /// **'Saved on {date}'** + String formatSavedOn(String date); + + /// Trending topic category subtitle line + /// + /// In en, this message translates to: + /// **'Category: {category}'** + String formatTrendingCategory(String category); + + /// Trending topic post count subtitle line + /// + /// In en, this message translates to: + /// **'{count, plural, =1{1 post} other{{count} posts}}'** + String formatTrendingPostCount(int count); + + /// Post overflow action label to view an author profile + /// + /// In en, this message translates to: + /// **'View @{handle}'** + String formatViewHandle(String handle); + + /// Snackbar message when the image picker fails + /// + /// In en, this message translates to: + /// **'Failed to pick image: {error}'** + String formatComposeFailedToPickImage(Object error); + + /// Snackbar message when the video picker fails + /// + /// In en, this message translates to: + /// **'Failed to pick video: {error}'** + String formatComposeFailedToPickVideo(Object error); + + /// Compose error message when saving edits fails with details + /// + /// In en, this message translates to: + /// **'Failed to save changes: {error}'** + String formatComposeFailedToSaveChanges(Object error); + + /// Compose error message when post submission fails with details + /// + /// In en, this message translates to: + /// **'Failed to submit post: {error}'** + String formatComposeFailedToSubmitPost(Object error); + + /// Compose validation error when an attached image is too large + /// + /// In en, this message translates to: + /// **'Image \"{fileName}\" is {sizeMb} MB - max 1 MB.'** + String formatComposeImageTooLarge(String fileName, String sizeMb); + + /// Compose quote preview label with the quoted author's handle + /// + /// In en, this message translates to: + /// **'Quoting @{handle}'** + String formatComposeQuotingHandle(String handle); + + /// Compose scheduled post pill label + /// + /// In en, this message translates to: + /// **'Scheduled for {dateTime}'** + String formatComposeScheduledFor(String dateTime); + + /// Video attachment status when ready and alt text exists + /// + /// In en, this message translates to: + /// **'Ready - \"{altText}\"'** + String formatComposeVideoReadyWithAltText(String altText); + + /// Compose validation error when a video is too large + /// + /// In en, this message translates to: + /// **'Video is {sizeMb} MB - exceeds the 100 MB limit.'** + String formatComposeVideoTooLarge(String sizeMb); + + /// Compose drafts panel count label + /// + /// In en, this message translates to: + /// **'{count, plural, =1{1 draft} other{{count} drafts}}'** + String formatDraftCount(int count); + + /// Offline action phrase for liking a post + /// + /// In en, this message translates to: + /// **'like this post'** + String get actionLikeThisPost; + + /// Offline action phrase for replying to a post + /// + /// In en, this message translates to: + /// **'reply to this post'** + String get actionReplyToThisPost; + + /// Offline action phrase for reposting a post + /// + /// In en, this message translates to: + /// **'repost this post'** + String get actionRepostThisPost; + + /// Offline action phrase for publishing a post + /// + /// In en, this message translates to: + /// **'publish your post'** + String get actionPublishYourPost; + /// About page or settings label /// /// In en, this message translates to: @@ -409,6 +739,36 @@ abstract class AppLocalizations { /// **'Bookmarks & Likes'** String get labelBookmarksAndLikes; + /// Tooltip for bookmark actions menu + /// + /// In en, this message translates to: + /// **'Bookmark actions'** + String get labelBookmarkActions; + + /// Fallback saved-post card title + /// + /// In en, this message translates to: + /// **'Bookmarked Post'** + String get labelBookmarkedPost; + + /// Bookmarks tab label + /// + /// In en, this message translates to: + /// **'Bookmarks'** + String get labelBookmarks; + + /// Bluesky source tab label + /// + /// In en, this message translates to: + /// **'Bluesky'** + String get labelBluesky; + + /// Short all-caps label for media alt text controls + /// + /// In en, this message translates to: + /// **'ALT'** + String get labelAlt; + /// Snackbar message after clearing cache /// /// In en, this message translates to: @@ -427,6 +787,12 @@ abstract class AppLocalizations { /// **'Clean Follows'** String get labelCleanFollows; + /// Close button tooltip + /// + /// In en, this message translates to: + /// **'Close'** + String get labelClose; + /// Settings item for clearing cache /// /// In en, this message translates to: @@ -619,6 +985,12 @@ abstract class AppLocalizations { /// **'New Post'** String get labelNewPost; + /// Uppercase compact relative time label for the current moment + /// + /// In en, this message translates to: + /// **'NOW'** + String get labelNow; + /// Notifications menu label /// /// In en, this message translates to: @@ -799,145 +1171,343 @@ abstract class AppLocalizations { /// **'Clear until'** String get labelClearUntil; - /// Saved accounts section label on login screen + /// Bookmark menu item to clear local bookmarks /// /// In en, this message translates to: - /// **'Saved accounts'** - String get labelSavedAccounts; + /// **'Clear local bookmarks'** + String get labelClearLocalBookmarks; - /// Bottom navigation search label + /// Post overflow menu item to copy a post link /// /// In en, this message translates to: - /// **'SEARCH'** - String get labelSearchNav; + /// **'Copy Link'** + String get labelCopyLink; - /// Semantic search settings label + /// Post overflow menu item to delete a post /// /// In en, this message translates to: - /// **'Semantic Search'** - String get labelSemanticSearch; + /// **'Delete Post'** + String get labelDeletePost; - /// Settings page title or tooltip + /// Tooltip for deleting a draft /// /// In en, this message translates to: - /// **'Settings'** - String get labelSettings; + /// **'Delete draft'** + String get labelDeleteDraft; - /// Show button label + /// Post overflow menu item to edit a post /// /// In en, this message translates to: - /// **'Show'** - String get labelShow; + /// **'Edit Post'** + String get labelEditPost; - /// Fallback account handle text when sign in is required + /// Liked posts tab label /// /// In en, this message translates to: - /// **'Sign in required'** - String get labelSignInRequired; + /// **'Liked'** + String get labelLiked; - /// Slingshot identity fallback setting label + /// Post interactions sheet section label for users who liked a post /// /// In en, this message translates to: - /// **'Slingshot Identity Fallback'** - String get labelSlingshotIdentityFallback; + /// **'LIKED BY'** + String get labelLikedBy; - /// Tooltip while sign in is starting + /// Fallback liked-post card title /// /// In en, this message translates to: - /// **'Starting sign in'** - String get labelStartingSignIn; + /// **'Liked Post'** + String get labelLikedPost; - /// System theme option label + /// Tooltip for opening more information /// /// In en, this message translates to: - /// **'System'** - String get labelSystem; + /// **'More info'** + String get labelMoreInfo; - /// Terms of service link label + /// Local saved-post source tab label /// /// In en, this message translates to: - /// **'Terms of Service'** - String get labelTermsOfService; + /// **'Local'** + String get labelLocal; - /// Theme subsection label in settings + /// Tooltip to open a post /// /// In en, this message translates to: - /// **'THEME'** - String get labelTheme; + /// **'Open post'** + String get labelOpenPost; - /// Thread auto-collapse setting label + /// Post action label to quote a post /// /// In en, this message translates to: - /// **'Thread Auto-Collapse'** - String get labelThreadAutoCollapse; + /// **'Quote Post'** + String get labelQuotePost; - /// Troubleshooting settings section label + /// Heading for quote and repost bottom sheet /// /// In en, this message translates to: - /// **'Troubleshooting'** - String get labelTroubleshooting; + /// **'QUOTE / REPOSTS'** + String get labelQuoteReposts; - /// Typeahead provider settings label + /// Quotes section title /// /// In en, this message translates to: - /// **'Typeahead Provider'** - String get labelTypeaheadProvider; + /// **'Quotes'** + String get labelQuotes; - /// Video upload limits settings label + /// Post save menu item to remove a cloud bookmark /// /// In en, this message translates to: - /// **'Video Upload Limits'** - String get labelVideoUploadLimits; + /// **'Remove from Bluesky'** + String get labelRemoveFromBluesky; - /// Debug app password help text + /// Post save menu item to remove a local bookmark /// /// In en, this message translates to: - /// **'Can be generated via Bluesky\'\'s App Passwords section at bsky.app.'** - String get messageAppPasswordGeneratedViaBluesky; + /// **'Remove local save'** + String get labelRemoveLocalSave; - /// Snackbar message after arming debug unauthorized response + /// Post overflow menu item to report a post /// /// In en, this message translates to: - /// **'Armed: next XRPC request will return debug 401 Unauthorized'** - String get messageAppViewDebug401Armed; + /// **'Report Post'** + String get labelReportPost; - /// Settings subtitle for Bluesky typeahead provider + /// Post action label to repost a post /// /// In en, this message translates to: - /// **'Bluesky official endpoint selected.'** - String get messageBlueskyEndpointSelected; + /// **'Repost'** + String get labelRepost; - /// Settings subtitle for bookmarks and likes + /// Post interactions sheet section label for users who reposted a post /// /// In en, this message translates to: - /// **'View your bookmarked and liked posts'** - String get messageBookmarksAndLikesSubtitle; + /// **'REPOSTED BY'** + String get labelRepostedBy; - /// Login screen provider selection helper text + /// Reposts section title /// /// In en, this message translates to: - /// **'Choose the AppView provider used for sign in and public reads.'** - String get messageChooseProviderSubtitle; + /// **'Reposts'** + String get labelReposts; - /// Settings subtitle for clean follows + /// Image context menu item to save an image /// /// In en, this message translates to: - /// **'Audit and unfollow problematic accounts in bulk'** - String get messageCleanFollowsSubtitle; + /// **'Save image'** + String get labelSaveImage; - /// Settings subtitle for clearing cache + /// Post save menu item to create a local bookmark /// /// In en, this message translates to: - /// **'Remove cached posts, profiles, images, feeds, threads, and semantic search data'** - String get messageClearCacheSubtitle; + /// **'Save locally'** + String get labelSaveLocally; - /// Settings subtitle for community typeahead provider + /// Post save menu item to create a cloud bookmark /// /// In en, this message translates to: - /// **'Community (waow.tech) selected. Third-party service.'** - String get messageCommunityTypeaheadSelected; + /// **'Save to Bluesky'** + String get labelSaveToBluesky; - /// Settings subtitle for content moderation + /// Compose toolbar schedule button tooltip + /// + /// In en, this message translates to: + /// **'Schedule'** + String get labelSchedule; + + /// Scheduled draft badge label + /// + /// In en, this message translates to: + /// **'Scheduled'** + String get labelScheduled; + + /// Saved accounts section label on login screen + /// + /// In en, this message translates to: + /// **'Saved accounts'** + String get labelSavedAccounts; + + /// Bottom navigation search label + /// + /// In en, this message translates to: + /// **'SEARCH'** + String get labelSearchNav; + + /// Semantic search settings label + /// + /// In en, this message translates to: + /// **'Semantic Search'** + String get labelSemanticSearch; + + /// Post overflow menu item to open users who liked a post + /// + /// In en, this message translates to: + /// **'Show Liked Users'** + String get labelShowLikedUsers; + + /// Post overflow menu item to open quote and repost lists + /// + /// In en, this message translates to: + /// **'Show Quote/Repost List'** + String get labelShowQuoteRepostList; + + /// Settings page title or tooltip + /// + /// In en, this message translates to: + /// **'Settings'** + String get labelSettings; + + /// Show button label + /// + /// In en, this message translates to: + /// **'Show'** + String get labelShow; + + /// Fallback account handle text when sign in is required + /// + /// In en, this message translates to: + /// **'Sign in required'** + String get labelSignInRequired; + + /// Slingshot identity fallback setting label + /// + /// In en, this message translates to: + /// **'Slingshot Identity Fallback'** + String get labelSlingshotIdentityFallback; + + /// Tooltip while sign in is starting + /// + /// In en, this message translates to: + /// **'Starting sign in'** + String get labelStartingSignIn; + + /// System theme option label + /// + /// In en, this message translates to: + /// **'System'** + String get labelSystem; + + /// Terms of service link label + /// + /// In en, this message translates to: + /// **'Terms of Service'** + String get labelTermsOfService; + + /// Theme subsection label in settings + /// + /// In en, this message translates to: + /// **'THEME'** + String get labelTheme; + + /// Thread auto-collapse setting label + /// + /// In en, this message translates to: + /// **'Thread Auto-Collapse'** + String get labelThreadAutoCollapse; + + /// Troubleshooting settings section label + /// + /// In en, this message translates to: + /// **'Troubleshooting'** + String get labelTroubleshooting; + + /// Fallback video label + /// + /// In en, this message translates to: + /// **'Video'** + String get labelVideo; + + /// Trending topics section label + /// + /// In en, this message translates to: + /// **'Topics'** + String get labelTopics; + + /// Trending screen title and tooltip label + /// + /// In en, this message translates to: + /// **'Trending'** + String get labelTrending; + + /// Trending suggested topics section label + /// + /// In en, this message translates to: + /// **'Suggested'** + String get labelSuggested; + + /// Suggested follows sheet and menu label + /// + /// In en, this message translates to: + /// **'Suggested Follows'** + String get labelSuggestedFollows; + + /// Post action label to undo a repost + /// + /// In en, this message translates to: + /// **'Unrepost'** + String get labelUnrepost; + + /// Typeahead provider settings label + /// + /// In en, this message translates to: + /// **'Typeahead Provider'** + String get labelTypeaheadProvider; + + /// Video upload limits settings label + /// + /// In en, this message translates to: + /// **'Video Upload Limits'** + String get labelVideoUploadLimits; + + /// Debug app password help text + /// + /// In en, this message translates to: + /// **'Can be generated via Bluesky\'\'s App Passwords section at bsky.app.'** + String get messageAppPasswordGeneratedViaBluesky; + + /// Snackbar message after arming debug unauthorized response + /// + /// In en, this message translates to: + /// **'Armed: next XRPC request will return debug 401 Unauthorized'** + String get messageAppViewDebug401Armed; + + /// Settings subtitle for Bluesky typeahead provider + /// + /// In en, this message translates to: + /// **'Bluesky official endpoint selected.'** + String get messageBlueskyEndpointSelected; + + /// Settings subtitle for bookmarks and likes + /// + /// In en, this message translates to: + /// **'View your bookmarked and liked posts'** + String get messageBookmarksAndLikesSubtitle; + + /// Login screen provider selection helper text + /// + /// In en, this message translates to: + /// **'Choose the AppView provider used for sign in and public reads.'** + String get messageChooseProviderSubtitle; + + /// Settings subtitle for clean follows + /// + /// In en, this message translates to: + /// **'Audit and unfollow problematic accounts in bulk'** + String get messageCleanFollowsSubtitle; + + /// Settings subtitle for clearing cache + /// + /// In en, this message translates to: + /// **'Remove cached posts, profiles, images, feeds, threads, and semantic search data'** + String get messageClearCacheSubtitle; + + /// Settings subtitle for community typeahead provider + /// + /// In en, this message translates to: + /// **'Community (waow.tech) selected. Third-party service.'** + String get messageCommunityTypeaheadSelected; + + /// Settings subtitle for content moderation /// /// In en, this message translates to: /// **'Manage labelers and visibility rules'** @@ -1009,6 +1579,18 @@ abstract class AppLocalizations { /// **'Manage pinned and saved feeds'** String get messageFeedsSubtitle; + /// Snackbar message after copying a post link + /// + /// In en, this message translates to: + /// **'Link copied to clipboard'** + String get messageLinkCopiedToClipboard; + + /// Loading message while trending topics load + /// + /// In en, this message translates to: + /// **'Loading trending topics'** + String get messageLoadingTrendingTopics; + /// Developer setting subtitle for forced 401 /// /// In en, this message translates to: @@ -1021,6 +1603,12 @@ abstract class AppLocalizations { /// **'Manage semantic search from Bookmarks & Likes -> Search'** String get messageManageSemanticSearchSubtitle; + /// Trending banner shown when supplemental metadata cannot be loaded + /// + /// In en, this message translates to: + /// **'Metadata temporarily unavailable'** + String get messageMetadataTemporarilyUnavailable; + /// Moderation overlay description when content cannot be revealed /// /// In en, this message translates to: @@ -1039,149 +1627,2705 @@ abstract class AppLocalizations { /// **'Moderation/ranking can differ by provider. Verify health and recent fallback state.'** String get messageProviderDiagnosticsSubtitle; - /// Settings subtitle for refreshing provider health + /// Message shown when liked posts dependencies are unavailable /// /// In en, this message translates to: - /// **'Probe public AppView endpoints now'** - String get messageRefreshProviderHealthSubtitle; + /// **'Liked posts are unavailable right now.'** + String get messageLikedPostsUnavailable; - /// Settings subtitle for resetting sign-in data + /// Snackbar message after post edits are saved /// /// In en, this message translates to: - /// **'Troubleshoot OAuth or account-switching issues by clearing local sessions on this device'** - String get messageResetSignInDataSubtitle; + /// **'Changes saved.'** + String get messageChangesSaved; - /// Generic search subtitle + /// Tooltip for adding video alt text /// /// In en, this message translates to: - /// **'Search'** - String get messageSearchSubtitle; + /// **'Add alt text'** + String get messageComposeAddAltText; - /// Confirmation dialog body before clearing search history + /// Compose toolbar add image tooltip /// /// In en, this message translates to: - /// **'This will delete all your recent searches.'** - String get messageClearSearchHistoryContent; + /// **'Add image'** + String get messageComposeAddImage; - /// Confirmation dialog title before clearing search history + /// Compose toolbar add video tooltip /// /// In en, this message translates to: - /// **'Clear search history?'** - String get messageClearSearchHistoryTitle; + /// **'Add video'** + String get messageComposeAddVideo; - /// Hint shown in jump to profile dialog + /// Tooltip for clearing scheduled compose time /// /// In en, this message translates to: - /// **'Start typing to search handles.'** - String get messageStartTypingToSearchHandles; + /// **'Clear scheduled time'** + String get messageComposeClearScheduledTime; - /// Helper text when starter pack search is disabled + /// Image alt text field placeholder /// /// In en, this message translates to: - /// **'Starter pack search is not available in the API yet.'** - String get messageStarterPackSearchApiUnavailable; + /// **'Describe the image'** + String get messageComposeDescribeImage; - /// Starter pack search unavailable state title + /// Video alt text field placeholder /// /// In en, this message translates to: - /// **'Starter Pack Search Is Unavailable'** - String get messageStarterPackSearchUnavailableTitle; + /// **'Describe the video'** + String get messageComposeDescribeVideo; - /// Starter pack search unavailable state body + /// Snackbar message after saving a draft /// /// In en, this message translates to: - /// **'(Starter Pack Search is not yet implemented in the BlueSky API)'** - String get messageStarterPackSearchUnavailableBody; + /// **'Draft saved'** + String get messageComposeDraftSaved; - /// Button label to open upstream API issue + /// Compose drafts panel title and toolbar tooltip /// /// In en, this message translates to: - /// **'Track API progress'** - String get messageTrackApiProgress; + /// **'Drafts'** + String get messageComposeDrafts; - /// Snackbar message when opening upstream issue link fails + /// Compose edit mode notice banner /// /// In en, this message translates to: - /// **'Could not open issue link.'** - String get messageCouldNotOpenIssueLink; + /// **'Edits are saved by replacing the record while keeping this post URI. Ranking, counts, and visibility may shift while networks re-index.'** + String get messageComposeEditNotice; - /// Search posts field placeholder + /// Image alt text dialog title /// /// In en, this message translates to: - /// **'Search posts'** - String get messageSearchPostsPlaceholder; + /// **'Alt text'** + String get messageComposeImageAltTextTitle; - /// Search placeholder in profile posts-only mode + /// Compose validation message when too many images are attached /// /// In en, this message translates to: - /// **'Search this profile\'\'s posts'** - String get messageSearchThisProfilesPostsPlaceholder; + /// **'Maximum 4 images allowed'** + String get messageComposeImageMaxCount; - /// Search people field placeholder + /// Compose validation message for unsupported image extensions /// /// In en, this message translates to: - /// **'Search people'** - String get messageSearchPeoplePlaceholder; + /// **'Image must be JPEG, PNG, or WebP'** + String get messageComposeImageMustBeJpegPngWebp; - /// Search feeds field placeholder + /// Compose validation message for image picker size validation /// /// In en, this message translates to: - /// **'Search feeds'** - String get messageSearchFeedsPlaceholder; + /// **'Image must be smaller than 1MB'** + String get messageComposeImageMustBeUnder1Mb; - /// Starter pack search field placeholder + /// Empty state text in compose drafts panel /// /// In en, this message translates to: - /// **'Starter pack search unavailable'** - String get messageStarterPackSearchUnavailablePlaceholder; + /// **'No drafts saved'** + String get messageComposeNoDraftsSaved; - /// Settings subtitle for Slingshot identity fallback + /// Fallback draft content label when a draft has no text /// /// In en, this message translates to: - /// **'If handle lookup fails, use Slingshot to find your DID and PDS so sign-in can continue'** - String get messageSlingshotIdentityFallbackSubtitle; + /// **'(No text)'** + String get messageComposeNoText; - /// Settings subtitle for thread auto-collapse + /// Compose text field placeholder /// /// In en, this message translates to: - /// **'Collapse reply branches deeper than the selected level'** - String get messageThreadAutoCollapseSubtitle; + /// **'What\'\'s on your mind?'** + String get messageComposePlaceholder; - /// Settings subtitle for animations + /// Video alt text preview unavailable message /// /// In en, this message translates to: - /// **'Turn off non-essential motion effects'** - String get messageTurnOffNonEssentialMotion; + /// **'Preview unavailable'** + String get messageComposePreviewUnavailable; - /// Settings subtitle for video upload limits + /// Compose quote preview label without an author handle /// /// In en, this message translates to: - /// **'Check your daily video quota'** - String get messageVideoUploadLimitsSubtitle; + /// **'Quoting post'** + String get messageComposeQuotingPost; - /// Placeholder for app password field + /// Compose validation message when a video cannot be added because other media exists /// /// In en, this message translates to: - /// **'xxxx-xxxx-xxxx-xxxx'** - String get placeholderAppPassword; + /// **'Remove existing media before adding a video'** + String get messageComposeRemoveExistingMediaBeforeVideo; - /// Placeholder for handle or DID sign-in field + /// Tooltip for removing an image attachment /// /// In en, this message translates to: - /// **'username.bsky.social or did:plc:...'** - String get placeholderHandleOrDid; + /// **'Remove image'** + String get messageComposeRemoveImage; - /// Label for handle or DID sign-in field + /// Tooltip for removing a quoted post from compose /// /// In en, this message translates to: - /// **'Handle or DID'** - String get promptHandleOrDid; + /// **'Remove quoted post'** + String get messageComposeRemoveQuotedPost; - /// Validation error for missing app password + /// Compose toolbar save draft tooltip /// /// In en, this message translates to: - /// **'Enter your app password'** - String get validationEnterAppPassword; + /// **'Save draft'** + String get messageComposeSaveDraft; + + /// Video alt text dialog title + /// + /// In en, this message translates to: + /// **'Video alt text'** + String get messageComposeVideoAltTextTitle; + + /// Video attachment status while checking upload limits + /// + /// In en, this message translates to: + /// **'Checking upload limits...'** + String get messageVideoCheckingUploadLimits; + + /// Video upload validation message when the daily limit is reached + /// + /// In en, this message translates to: + /// **'Daily video upload limit reached.'** + String get messageVideoDailyUploadLimitReached; + + /// Video attachment processing status + /// + /// In en, this message translates to: + /// **'Processing...'** + String get messageVideoProcessing; + + /// Video attachment error when processing fails + /// + /// In en, this message translates to: + /// **'Video processing failed.'** + String get messageVideoProcessingFailed; + + /// Video attachment error when processing times out + /// + /// In en, this message translates to: + /// **'Video processing timed out.'** + String get messageVideoProcessingTimedOut; + + /// Video attachment ready status + /// + /// In en, this message translates to: + /// **'Ready'** + String get messageVideoReady; + + /// Video attachment ready-to-upload status + /// + /// In en, this message translates to: + /// **'Ready to upload'** + String get messageVideoReadyToUpload; + + /// Video upload generic failure message + /// + /// In en, this message translates to: + /// **'Upload failed - please try again.'** + String get messageVideoUploadFailed; + + /// Video attachment uploading status + /// + /// In en, this message translates to: + /// **'Uploading...'** + String get messageVideoUploading; + + /// Compose edit error when the post changed remotely + /// + /// In en, this message translates to: + /// **'This post was changed elsewhere. Reopen it and try editing again.'** + String get errorComposeChangedElsewhere; + + /// Compose edit error when an edit cannot be confirmed + /// + /// In en, this message translates to: + /// **'Edit was submitted but could not be confirmed yet. Please reopen the post and verify.'** + String get errorComposeCouldNotConfirmEdit; + + /// Compose edit recovery error when save and recovery status are unknown + /// + /// In en, this message translates to: + /// **'Could not save changes and we could not confirm recovery. Reopen the thread and verify the post.'** + String get errorComposeCouldNotSaveAndConfirmRecovery; + + /// Compose edit validation error when edit context is missing + /// + /// In en, this message translates to: + /// **'Edit context is missing. Please reopen the editor and try again.'** + String get errorComposeEditContextMissing; + + /// Compose error when creating a post fails + /// + /// In en, this message translates to: + /// **'Failed to create post. Please try again.'** + String get errorComposeFailedToCreatePost; + + /// Compose error when saving post edits fails + /// + /// In en, this message translates to: + /// **'Failed to save changes. Please try again.'** + String get errorComposeFailedToSaveChanges; + + /// Compose error when image upload fails + /// + /// In en, this message translates to: + /// **'Failed to upload image. Please try again.'** + String get errorComposeFailedToUploadImage; + + /// Compose error when an attached image file is missing + /// + /// In en, this message translates to: + /// **'Image file not found. Please re-attach and try again.'** + String get errorComposeImageFileNotFound; + + /// Compose error when submission fails and is saved as a draft + /// + /// In en, this message translates to: + /// **'Network error - post saved as draft.'** + String get errorComposeNetworkSavedAsDraft; + + /// Compose edit error when the original post was restored after failure + /// + /// In en, this message translates to: + /// **'Could not save changes. Your original post was restored.'** + String get errorComposeOriginalPostRestored; + + /// Compose validation error for unsupported image bytes + /// + /// In en, this message translates to: + /// **'Unsupported image format. Use JPEG, PNG, or WebP.'** + String get errorComposeUnsupportedImageFormat; + + /// Empty state title when there are no bookmarks + /// + /// In en, this message translates to: + /// **'No bookmarks'** + String get messageNoBookmarks; + + /// Empty state subtitle when there are no bookmarks + /// + /// In en, this message translates to: + /// **'Posts you bookmark will appear here'** + String get messageNoBookmarksSubtitle; + + /// Empty state title when a bookmark source tab is empty + /// + /// In en, this message translates to: + /// **'No bookmarks in this source'** + String get messageNoBookmarksInSource; + + /// Empty state subtitle when a bookmark source tab is empty + /// + /// In en, this message translates to: + /// **'Try switching tabs or saving posts to this source'** + String get messageNoBookmarksInSourceSubtitle; + + /// Empty state message when a post has no likes or reposts + /// + /// In en, this message translates to: + /// **'No interactions yet'** + String get messageNoInteractionsYet; + + /// Empty state title when there are no liked posts + /// + /// In en, this message translates to: + /// **'No liked posts'** + String get messageNoLikedPosts; + + /// Empty state subtitle when there are no liked posts + /// + /// In en, this message translates to: + /// **'Posts you like will appear here after sync'** + String get messageNoLikedPostsSubtitle; + + /// Empty state when a profile has no liked posts + /// + /// In en, this message translates to: + /// **'No liked posts yet'** + String get messageNoLikedPostsYet; + + /// Empty state message when a post has no quote posts + /// + /// In en, this message translates to: + /// **'No quotes yet'** + String get messageNoQuotesYet; + + /// Empty state message when a post has no reposts + /// + /// In en, this message translates to: + /// **'No reposts yet'** + String get messageNoRepostsYet; + + /// Empty state message when there are no trending topics + /// + /// In en, this message translates to: + /// **'No trending topics right now'** + String get messageNoTrendingTopicsRightNow; + + /// Snackbar message after deleting a post + /// + /// In en, this message translates to: + /// **'Post deleted'** + String get messagePostDeleted; + + /// Post action subtitle for quote post + /// + /// In en, this message translates to: + /// **'Quote this post with your own text'** + String get messageQuotePostSubtitle; + + /// Quoted embed unavailable message for blocked quoted posts + /// + /// In en, this message translates to: + /// **'Quoted post is blocked'** + String get messageQuotedPostBlocked; + + /// Quoted embed unavailable message for missing quoted posts + /// + /// In en, this message translates to: + /// **'Quoted post not found'** + String get messageQuotedPostNotFound; + + /// Quoted embed unavailable message for detached or unavailable quoted posts + /// + /// In en, this message translates to: + /// **'Quoted post is unavailable'** + String get messageQuotedPostUnavailable; + + /// Post action subtitle for removing a repost + /// + /// In en, this message translates to: + /// **'Remove this repost'** + String get messageRemoveRepostSubtitle; + + /// Post card reply context label when parent post details are unavailable + /// + /// In en, this message translates to: + /// **'Reply in a thread'** + String get messageReplyInThread; + + /// Compose reply banner prefix before the replied-to handle + /// + /// In en, this message translates to: + /// **'Replying to'** + String get messageReplyingTo; + + /// Post action subtitle for reposting a post + /// + /// In en, this message translates to: + /// **'Share this post'** + String get messageShareThisPostSubtitle; + + /// Post overflow subtitle for viewing users who liked a post + /// + /// In en, this message translates to: + /// **'View who liked this post'** + String get messageShowLikedUsersSubtitle; + + /// Post overflow subtitle for viewing quotes and reposts + /// + /// In en, this message translates to: + /// **'View quote posts and expand reposts'** + String get messageShowQuoteRepostListSubtitle; + + /// Settings subtitle for refreshing provider health + /// + /// In en, this message translates to: + /// **'Probe public AppView endpoints now'** + String get messageRefreshProviderHealthSubtitle; + + /// Settings subtitle for resetting sign-in data + /// + /// In en, this message translates to: + /// **'Troubleshoot OAuth or account-switching issues by clearing local sessions on this device'** + String get messageResetSignInDataSubtitle; + + /// Generic search subtitle + /// + /// In en, this message translates to: + /// **'Search'** + String get messageSearchSubtitle; + + /// Confirmation dialog body before clearing search history + /// + /// In en, this message translates to: + /// **'This will delete all your recent searches.'** + String get messageClearSearchHistoryContent; + + /// Confirmation dialog title before clearing search history + /// + /// In en, this message translates to: + /// **'Clear search history?'** + String get messageClearSearchHistoryTitle; + + /// Hint shown in jump to profile dialog + /// + /// In en, this message translates to: + /// **'Start typing to search handles.'** + String get messageStartTypingToSearchHandles; + + /// Helper text when starter pack search is disabled + /// + /// In en, this message translates to: + /// **'Starter pack search is not available in the API yet.'** + String get messageStarterPackSearchApiUnavailable; + + /// Starter pack search unavailable state title + /// + /// In en, this message translates to: + /// **'Starter Pack Search Is Unavailable'** + String get messageStarterPackSearchUnavailableTitle; + + /// Starter pack search unavailable state body + /// + /// In en, this message translates to: + /// **'(Starter Pack Search is not yet implemented in the BlueSky API)'** + String get messageStarterPackSearchUnavailableBody; + + /// Button label to open upstream API issue + /// + /// In en, this message translates to: + /// **'Track API progress'** + String get messageTrackApiProgress; + + /// Snackbar message when opening upstream issue link fails + /// + /// In en, this message translates to: + /// **'Could not open issue link.'** + String get messageCouldNotOpenIssueLink; + + /// Search posts field placeholder + /// + /// In en, this message translates to: + /// **'Search posts'** + String get messageSearchPostsPlaceholder; + + /// Search placeholder in profile posts-only mode + /// + /// In en, this message translates to: + /// **'Search this profile\'\'s posts'** + String get messageSearchThisProfilesPostsPlaceholder; + + /// Search people field placeholder + /// + /// In en, this message translates to: + /// **'Search people'** + String get messageSearchPeoplePlaceholder; + + /// Search field placeholder when adding list members + /// + /// In en, this message translates to: + /// **'Search for people'** + String get messageSearchForPeoplePlaceholder; + + /// Search feeds field placeholder + /// + /// In en, this message translates to: + /// **'Search feeds'** + String get messageSearchFeedsPlaceholder; + + /// Starter pack search field placeholder + /// + /// In en, this message translates to: + /// **'Starter pack search unavailable'** + String get messageStarterPackSearchUnavailablePlaceholder; + + /// Settings subtitle for Slingshot identity fallback + /// + /// In en, this message translates to: + /// **'If handle lookup fails, use Slingshot to find your DID and PDS so sign-in can continue'** + String get messageSlingshotIdentityFallbackSubtitle; + + /// Settings subtitle for thread auto-collapse + /// + /// In en, this message translates to: + /// **'Collapse reply branches deeper than the selected level'** + String get messageThreadAutoCollapseSubtitle; + + /// Settings subtitle for animations + /// + /// In en, this message translates to: + /// **'Turn off non-essential motion effects'** + String get messageTurnOffNonEssentialMotion; + + /// Settings subtitle for video upload limits + /// + /// In en, this message translates to: + /// **'Check your daily video quota'** + String get messageVideoUploadLimitsSubtitle; + + /// Placeholder for app password field + /// + /// In en, this message translates to: + /// **'xxxx-xxxx-xxxx-xxxx'** + String get placeholderAppPassword; + + /// Placeholder for handle or DID sign-in field + /// + /// In en, this message translates to: + /// **'username.bsky.social or did:plc:...'** + String get placeholderHandleOrDid; + + /// Label for handle or DID sign-in field + /// + /// In en, this message translates to: + /// **'Handle or DID'** + String get promptHandleOrDid; + + /// Button label to add a feed + /// + /// In en, this message translates to: + /// **'Add feed'** + String get buttonAddFeed; + + /// Button label to add members to a list + /// + /// In en, this message translates to: + /// **'Add members'** + String get buttonAddMembers; + + /// Button label to block an account + /// + /// In en, this message translates to: + /// **'Block'** + String get buttonBlock; + + /// Create button label + /// + /// In en, this message translates to: + /// **'Create'** + String get buttonCreate; + + /// Edit button label + /// + /// In en, this message translates to: + /// **'Edit'** + String get buttonEdit; + + /// Button label to follow an account + /// + /// In en, this message translates to: + /// **'Follow'** + String get buttonFollow; + + /// Button label to follow all starter pack members + /// + /// In en, this message translates to: + /// **'Follow all'** + String get buttonFollowAll; + + /// Button label showing that an account is followed + /// + /// In en, this message translates to: + /// **'Following'** + String get buttonFollowing; + + /// Button label while following all starter pack members + /// + /// In en, this message translates to: + /// **'Following…'** + String get buttonFollowingInProgress; + + /// Button label to load more results + /// + /// In en, this message translates to: + /// **'Load more'** + String get buttonLoadMore; + + /// Button label to mute an account or list + /// + /// In en, this message translates to: + /// **'Mute'** + String get buttonMute; + + /// Button label to start a follow audit scan + /// + /// In en, this message translates to: + /// **'Scan'** + String get buttonScan; + + /// Button label to view all items + /// + /// In en, this message translates to: + /// **'See all'** + String get buttonSeeAll; + + /// Button label to reveal blocked-by accounts + /// + /// In en, this message translates to: + /// **'Show accounts'** + String get buttonShowAccounts; + + /// Button label to submit a moderation report + /// + /// In en, this message translates to: + /// **'Submit Report'** + String get buttonSubmitReport; + + /// Button label to unblock an account + /// + /// In en, this message translates to: + /// **'Unblock'** + String get buttonUnblock; + + /// Button label to unfollow an account + /// + /// In en, this message translates to: + /// **'Unfollow'** + String get buttonUnfollow; + + /// Button label to unfollow selected audit results + /// + /// In en, this message translates to: + /// **'Unfollow Selected ({count})'** + String buttonUnfollowSelected(int count); + + /// Button label to unmute an account or list + /// + /// In en, this message translates to: + /// **'Unmute'** + String get buttonUnmute; + + /// Confirmation dialog body before blocking an account + /// + /// In en, this message translates to: + /// **'They will not be able to see your posts or interact with you. They will not be notified that you blocked them.'** + String get dialogBlockAccountContent; + + /// Confirmation dialog title before blocking an account + /// + /// In en, this message translates to: + /// **'Block Account?'** + String get dialogBlockAccountTitle; + + /// Confirmation dialog title before deleting a list + /// + /// In en, this message translates to: + /// **'Delete list?'** + String get dialogDeleteListTitle; + + /// Confirmation dialog body before deleting a starter pack + /// + /// In en, this message translates to: + /// **'This will permanently delete this starter pack and its backing list. This cannot be undone.'** + String get dialogDeleteStarterPackContent; + + /// Confirmation dialog title before deleting a starter pack + /// + /// In en, this message translates to: + /// **'Delete starter pack'** + String get dialogDeleteStarterPackTitle; + + /// Confirmation dialog body before muting an account + /// + /// In en, this message translates to: + /// **'You will no longer see their posts or receive notifications from them.'** + String get dialogMuteAccountContent; + + /// Confirmation dialog title before muting an account + /// + /// In en, this message translates to: + /// **'Mute Account?'** + String get dialogMuteAccountTitle; + + /// Confirmation dialog body before unblocking an account + /// + /// In en, this message translates to: + /// **'They will be able to see your posts and interact with you again.'** + String get dialogUnblockAccountContent; + + /// Confirmation dialog title before unblocking an account + /// + /// In en, this message translates to: + /// **'Unblock Account?'** + String get dialogUnblockAccountTitle; + + /// Confirmation dialog body before unfollowing an account + /// + /// In en, this message translates to: + /// **'You will no longer see their posts in your feed.'** + String get dialogUnfollowAccountContent; + + /// Confirmation dialog title before unfollowing an account + /// + /// In en, this message translates to: + /// **'Unfollow?'** + String get dialogUnfollowAccountTitle; + + /// Confirmation dialog body before unmuting an account + /// + /// In en, this message translates to: + /// **'You will see their posts and receive notifications again.'** + String get dialogUnmuteAccountContent; + + /// Confirmation dialog title before unmuting an account + /// + /// In en, this message translates to: + /// **'Unmute Account?'** + String get dialogUnmuteAccountTitle; + + /// Error message when starter pack creation fails + /// + /// In en, this message translates to: + /// **'Failed to create starter pack'** + String get errorFailedToCreateStarterPack; + + /// Error message when accounts cannot load + /// + /// In en, this message translates to: + /// **'Failed to load accounts'** + String get errorFailedToLoadAccounts; + + /// Error message when a list feed cannot load + /// + /// In en, this message translates to: + /// **'Failed to load feed'** + String get errorFailedToLoadFeed; + + /// Error message when feed picker suggestions cannot load + /// + /// In en, this message translates to: + /// **'Failed to load feeds'** + String get errorFailedToLoadFeeds; + + /// Error message when a list cannot load + /// + /// In en, this message translates to: + /// **'Failed to load list'** + String get errorFailedToLoadList; + + /// Error message when lists cannot load + /// + /// In en, this message translates to: + /// **'Failed to load lists'** + String get errorFailedToLoadLists; + + /// Error message when list members cannot load + /// + /// In en, this message translates to: + /// **'Failed to load members'** + String get errorFailedToLoadMembers; + + /// Error message when loading more results fails + /// + /// In en, this message translates to: + /// **'Failed to load more'** + String get errorFailedToLoadMore; + + /// Error message when posts cannot load + /// + /// In en, this message translates to: + /// **'Failed to load posts'** + String get errorFailedToLoadPosts; + + /// Error title when a profile cannot load + /// + /// In en, this message translates to: + /// **'Unable to load profile'** + String get errorFailedToLoadProfile; + + /// Error message when a starter pack cannot load + /// + /// In en, this message translates to: + /// **'Failed to load starter pack'** + String get errorFailedToLoadStarterPack; + + /// Error message when starter packs cannot load + /// + /// In en, this message translates to: + /// **'Failed to load starter packs'** + String get errorFailedToLoadStarterPacks; + + /// Error title when suggested follows cannot load + /// + /// In en, this message translates to: + /// **'Failed to load suggestions'** + String get errorFailedToLoadSuggestions; + + /// Error message when follow audit fails + /// + /// In en, this message translates to: + /// **'Failed to complete follow audit.'** + String get errorFollowAuditFailed; + + /// Validation error when a profile image is too large + /// + /// In en, this message translates to: + /// **'Image must be smaller than 1MB'** + String get errorImageTooLarge; + + /// Validation error when profile image file type is unsupported + /// + /// In en, this message translates to: + /// **'Use a JPEG or PNG image'** + String get errorInvalidProfileImageType; + + /// Error when a selected profile image cannot be read + /// + /// In en, this message translates to: + /// **'Unable to read selected image'** + String get errorProfileImageReadFailed; + + /// Report submission failure dialog body + /// + /// In en, this message translates to: + /// **'Unable to submit your report. Please try again later.'** + String get errorReportFailed; + + /// Report submission failure dialog title + /// + /// In en, this message translates to: + /// **'Report Failed'** + String get errorReportFailedTitle; + + /// Error title when a profile connection tab cannot load + /// + /// In en, this message translates to: + /// **'Unable to load {tab}'** + String errorUnableToLoadConnections(String tab); + + /// Snackbar message when profile update fails + /// + /// In en, this message translates to: + /// **'Unable to update profile'** + String get errorUnableToUpdateProfile; + + /// Count of accounts + /// + /// In en, this message translates to: + /// **'{count, plural, =1{1 account} other{{count} accounts}}'** + String formatAccountCount(int count); + + /// Message when blocked-by account count exists but profiles are unavailable + /// + /// In en, this message translates to: + /// **'Found {count, plural, =1{1 blocked-by account} other{{count} blocked-by accounts}}, but public Bluesky profile details could not be loaded.'** + String formatBlockedByAccountsUnavailable(int count); + + /// Loading message for a profile connection tab + /// + /// In en, this message translates to: + /// **'Loading {tab}...'** + String formatConnectionsLoading(String tab); + + /// Empty search message for a profile connection tab + /// + /// In en, this message translates to: + /// **'No {tab} match \"{query}\"'** + String formatConnectionsNoMatches(String tab, String query); + + /// Empty message for a profile connection tab + /// + /// In en, this message translates to: + /// **'No {tab} found'** + String formatConnectionsNoneFound(String tab); + + /// Search progress message for profile connections + /// + /// In en, this message translates to: + /// **'Searching {count} accounts...'** + String formatConnectionsSearching(int count); + + /// Completed search progress message for profile connections + /// + /// In en, this message translates to: + /// **'Searched {count} accounts'** + String formatConnectionsSearched(int count); + + /// Stopped search progress message for profile connections + /// + /// In en, this message translates to: + /// **'Search stopped after {count} accounts'** + String formatConnectionsSearchStopped(int count); + + /// Follow audit classifying progress label + /// + /// In en, this message translates to: + /// **'Classifying: {progress}/{total}'** + String formatClassifyingProgress(int progress, int total); + + /// Snackbar message after copying a DID + /// + /// In en, this message translates to: + /// **'DID copied to clipboard'** + String get formatDidCopied; + + /// Follow audit fetching progress label + /// + /// In en, this message translates to: + /// **'Fetching follows: {progress}/{total}'** + String formatFetchingFollowsProgress(int progress, int total); + + /// Snackbar message after following starter pack members + /// + /// In en, this message translates to: + /// **'Followed {count, plural, =1{1 member} other{{count} members}}'** + String formatFollowedMemberCount(int count); + + /// Follow audit summary after scanning follows + /// + /// In en, this message translates to: + /// **'{count, plural, =1{1 follow scanned for problematic accounts} other{{count} follows scanned for problematic accounts}}'** + String formatFollowsScanned(int count); + + /// Tooltip to hide a follow audit status + /// + /// In en, this message translates to: + /// **'Hide {status}'** + String formatHideStatus(String status); + + /// Profile joined date label + /// + /// In en, this message translates to: + /// **'Joined {date}'** + String formatJoinedDate(String date); + + /// Profile card joined relative time label + /// + /// In en, this message translates to: + /// **'Joined {relativeTime}'** + String formatJoinedRelative(String relativeTime); + + /// List creator attribution label + /// + /// In en, this message translates to: + /// **'by @{handle}'** + String formatListByHandle(String handle); + + /// Count of list or starter pack members + /// + /// In en, this message translates to: + /// **'{count, plural, =1{1 member} other{{count} members}}'** + String formatMemberCount(int count); + + /// Report dialog title for a target handle + /// + /// In en, this message translates to: + /// **'{title} by @{handle}'** + String formatProfileReportTitle(String title, String handle); + + /// Profile edit validation message for a text length limit + /// + /// In en, this message translates to: + /// **'{label} must be {count} characters or fewer'** + String formatProfileTextLimit(String label, int count); + + /// Profile edit validation message for byte length + /// + /// In en, this message translates to: + /// **'{label} is too long'** + String formatProfileTextTooLong(String label); + + /// Follow audit warning when some profiles failed to load + /// + /// In en, this message translates to: + /// **'{count, plural, =1{1 profile could not be loaded.} other{{count} profiles could not be loaded.}}'** + String formatProfilesFailedToLoad(int count); + + /// Report submission success dialog body + /// + /// In en, this message translates to: + /// **'Thank you. Your report (ID: {reportId}) has been submitted.'** + String formatReportSubmitted(String reportId); + + /// Follow audit selected count footer + /// + /// In en, this message translates to: + /// **'Selected: {selected}/{total}'** + String formatSelectedCount(int selected, int total); + + /// Tooltip to show a follow audit status + /// + /// In en, this message translates to: + /// **'Show {status}'** + String formatShowStatus(String status); + + /// Title for unavailable accounts card + /// + /// In en, this message translates to: + /// **'Unavailable accounts ({count})'** + String formatUnavailableAccounts(int count); + + /// Follow audit completion message + /// + /// In en, this message translates to: + /// **'Unfollowed {count} account(s)'** + String formatUnfollowedAccounts(int count); + + /// Helper text for required character-limited fields + /// + /// In en, this message translates to: + /// **'Required, max {count} characters'** + String formatValidationRequiredMaxCharacters(int count); + + /// Action label to add an account to a list + /// + /// In en, this message translates to: + /// **'Add to list'** + String get labelAddToList; + + /// Follow audit screen title + /// + /// In en, this message translates to: + /// **'Audit Followers'** + String get labelAuditFollowers; + + /// Profile banner image button label + /// + /// In en, this message translates to: + /// **'Banner'** + String get labelBanner; + + /// List action label to block accounts via a moderation list + /// + /// In en, this message translates to: + /// **'Block via list'** + String get labelBlockViaList; + + /// Profile context tab label for accounts that blocked the user + /// + /// In en, this message translates to: + /// **'Blocked By'** + String get labelBlockedBy; + + /// Profile context tab label for accounts the user is blocking + /// + /// In en, this message translates to: + /// **'Blocking'** + String get labelBlocking; + + /// Profile connections screen title + /// + /// In en, this message translates to: + /// **'Connections'** + String get labelConnections; + + /// Profile action label to copy DID + /// + /// In en, this message translates to: + /// **'Copy DID'** + String get labelCopyDid; + + /// Create list dialog title + /// + /// In en, this message translates to: + /// **'Create list'** + String get labelCreateList; + + /// Tooltip to create a starter pack + /// + /// In en, this message translates to: + /// **'Create starter pack'** + String get labelCreateStarterPack; + + /// Short curation list badge label + /// + /// In en, this message translates to: + /// **'CURATE'** + String get labelCurateShort; + + /// List members section heading + /// + /// In en, this message translates to: + /// **'Current Members'** + String get labelCurrentMembers; + + /// Profile context list section heading + /// + /// In en, this message translates to: + /// **'Curation Lists'** + String get labelCurationLists; + + /// Description field label + /// + /// In en, this message translates to: + /// **'Description'** + String get labelDescription; + + /// Optional description field label + /// + /// In en, this message translates to: + /// **'Description (optional)'** + String get labelDescriptionOptional; + + /// Profile display name field label + /// + /// In en, this message translates to: + /// **'Display name'** + String get labelDisplayName; + + /// Edit list dialog or action title + /// + /// In en, this message translates to: + /// **'Edit list'** + String get labelEditList; + + /// Edit profile screen title or action label + /// + /// In en, this message translates to: + /// **'Edit profile'** + String get labelEditProfile; + + /// Edit starter pack dialog title + /// + /// In en, this message translates to: + /// **'Edit starter pack'** + String get labelEditStarterPack; + + /// Feed label + /// + /// In en, this message translates to: + /// **'Feed'** + String get labelFeed; + + /// Followers label + /// + /// In en, this message translates to: + /// **'Followers'** + String get labelFollowers; + + /// Starter pack statistic label for joins this week + /// + /// In en, this message translates to: + /// **'joined this week'** + String get labelJoinedThisWeek; + + /// Starter pack card statistic label for total joins + /// + /// In en, this message translates to: + /// **'joined total'** + String get labelJoinedTotal; + + /// Following label + /// + /// In en, this message translates to: + /// **'Following'** + String get labelFollowing; + + /// Generic list title + /// + /// In en, this message translates to: + /// **'List'** + String get labelList; + + /// Lists label + /// + /// In en, this message translates to: + /// **'Lists'** + String get labelLists; + + /// Profile media tab label + /// + /// In en, this message translates to: + /// **'Media'** + String get labelMedia; + + /// Members section label + /// + /// In en, this message translates to: + /// **'Members'** + String get labelMembers; + + /// Profile context moderation list section heading + /// + /// In en, this message translates to: + /// **'Moderation Lists'** + String get labelModerationLists; + + /// Short moderation list badge label + /// + /// In en, this message translates to: + /// **'MOD'** + String get labelModerationShort; + + /// List action label to mute a list + /// + /// In en, this message translates to: + /// **'Mute list'** + String get labelMuteList; + + /// Mutual follows label + /// + /// In en, this message translates to: + /// **'Mutuals'** + String get labelMutuals; + + /// My lists screen title + /// + /// In en, this message translates to: + /// **'My Lists'** + String get labelMyLists; + + /// Name field label + /// + /// In en, this message translates to: + /// **'Name'** + String get labelName; + + /// New starter pack screen title + /// + /// In en, this message translates to: + /// **'New Starter Pack'** + String get labelNewStarterPack; + + /// Profile context other list section heading + /// + /// In en, this message translates to: + /// **'Other Lists'** + String get labelOtherLists; + + /// Pronouns field label + /// + /// In en, this message translates to: + /// **'Pronouns'** + String get labelPronouns; + + /// Profile context screen title and action label + /// + /// In en, this message translates to: + /// **'Profile Context'** + String get labelProfileContext; + + /// Generic profile screen title + /// + /// In en, this message translates to: + /// **'Profile'** + String get labelProfileTitle; + + /// Starter pack feeds section title + /// + /// In en, this message translates to: + /// **'Recommended Feeds'** + String get labelRecommendedFeeds; + + /// Profile context reference list section heading + /// + /// In en, this message translates to: + /// **'Reference Lists'** + String get labelReferenceLists; + + /// Short reference list badge label + /// + /// In en, this message translates to: + /// **'REFERENCE'** + String get labelReferenceShort; + + /// Profile replies tab label + /// + /// In en, this message translates to: + /// **'Replies'** + String get labelReplies; + + /// Report action label + /// + /// In en, this message translates to: + /// **'Report'** + String get labelReport; + + /// Report account dialog title + /// + /// In en, this message translates to: + /// **'Report Account'** + String get labelReportAccount; + + /// Report reason section label + /// + /// In en, this message translates to: + /// **'Reason'** + String get labelReportReason; + + /// Report explanation field label + /// + /// In en, this message translates to: + /// **'Explanation (required)'** + String get labelReportReasonExplanationRequired; + + /// Report reason label for harassment + /// + /// In en, this message translates to: + /// **'Harassment'** + String get labelReportReasonHarassment; + + /// Report reason label for misleading content + /// + /// In en, this message translates to: + /// **'Misleading'** + String get labelReportReasonMisleading; + + /// Report reason label for other + /// + /// In en, this message translates to: + /// **'Other'** + String get labelReportReasonOther; + + /// Report reason label for sexual content + /// + /// In en, this message translates to: + /// **'Sexual Content'** + String get labelReportReasonSexualContent; + + /// Report reason label for spam + /// + /// In en, this message translates to: + /// **'Spam'** + String get labelReportReasonSpam; + + /// Report reason label for violations + /// + /// In en, this message translates to: + /// **'Violation'** + String get labelReportReasonViolation; + + /// Report success dialog title + /// + /// In en, this message translates to: + /// **'Report Submitted'** + String get labelReportSubmitted; + + /// Select all checkbox label + /// + /// In en, this message translates to: + /// **'Select All'** + String get labelSelectAll; + + /// Feed picker sheet title + /// + /// In en, this message translates to: + /// **'Select a feed'** + String get labelSelectFeed; + + /// Profile action label to share a profile + /// + /// In en, this message translates to: + /// **'Share Profile'** + String get labelShareProfile; + + /// Fallback starter pack title + /// + /// In en, this message translates to: + /// **'Starter Pack'** + String get labelStarterPack; + + /// Type field label + /// + /// In en, this message translates to: + /// **'Type'** + String get labelType; + + /// Starter pack detail statistic label for total joins + /// + /// In en, this message translates to: + /// **'total joined'** + String get labelTotalJoined; + + /// Profile liked posts unavailable entry title + /// + /// In en, this message translates to: + /// **'Unavailable liked post'** + String get labelUnavailableLikedPost; + + /// List action label to unblock accounts via a moderation list + /// + /// In en, this message translates to: + /// **'Unblock via list'** + String get labelUnblockViaList; + + /// List action label to unmute a list + /// + /// In en, this message translates to: + /// **'Unmute list'** + String get labelUnmuteList; + + /// Helper label for selecting up to three starter pack feeds + /// + /// In en, this message translates to: + /// **'(up to 3)'** + String get labelUpToThree; + + /// Website field label + /// + /// In en, this message translates to: + /// **'Website'** + String get labelWebsite; + + /// Profile connection chip for the current user + /// + /// In en, this message translates to: + /// **'You'** + String get labelYou; + + /// Profile context blocked-by explanatory copy + /// + /// In en, this message translates to: + /// **'Blocks are a normal part of social media. This data is public on the AT Protocol.'** + String get messageBlockedByContextNotice; + + /// Profile context message when blocking list is not available + /// + /// In en, this message translates to: + /// **'Blocking information is only available when viewing your own profile.'** + String get messageBlockingOnlyOwnProfile; + + /// Tooltip and semantics label for changing profile avatar + /// + /// In en, this message translates to: + /// **'Change avatar image'** + String get messageChangeAvatarImage; + + /// Tooltip and semantics label for changing profile banner + /// + /// In en, this message translates to: + /// **'Change banner image'** + String get messageChangeBannerImage; + + /// Profile edit validation error for invalid website + /// + /// In en, this message translates to: + /// **'Enter a valid website'** + String get messageEnterValidWebsite; + + /// List detail message when moderation lists do not have feeds + /// + /// In en, this message translates to: + /// **'Feed not available for moderation lists'** + String get messageFeedUnavailableForModerationLists; + + /// Follow audit intro message before scanning + /// + /// In en, this message translates to: + /// **'Scan your follows for deleted, suspended, blocked, and hidden accounts.'** + String get messageFollowAuditIntro; + + /// Follow audit empty prompt before scanning + /// + /// In en, this message translates to: + /// **'Tap Scan to audit your follow list.'** + String get messageFollowAuditStartPrompt; + + /// Profile context empty blocked-by message + /// + /// In en, this message translates to: + /// **'No accounts have blocked this user'** + String get messageNoAccountsBlockedThisUser; + + /// Empty state when no lists exist + /// + /// In en, this message translates to: + /// **'No lists yet'** + String get messageNoListsYet; + + /// Empty starter pack members message + /// + /// In en, this message translates to: + /// **'No members'** + String get messageNoMembers; + + /// Empty list members message + /// + /// In en, this message translates to: + /// **'No members yet'** + String get messageNoMembersYet; + + /// Empty list members message with search instruction + /// + /// In en, this message translates to: + /// **'No members yet. Search above to add people.'** + String get messageNoMembersYetSearch; + + /// Empty profile media tab message + /// + /// In en, this message translates to: + /// **'No media posts yet'** + String get messageNoMediaPostsYet; + + /// Empty profile/list feed message + /// + /// In en, this message translates to: + /// **'No posts yet'** + String get messageNoPostsYet; + + /// Follow audit empty results message + /// + /// In en, this message translates to: + /// **'No problematic follows found'** + String get messageNoProblematicFollows; + + /// Empty profile replies message + /// + /// In en, this message translates to: + /// **'No replies yet'** + String get messageNoRepliesYet; + + /// Follow audit empty filtered results message + /// + /// In en, this message translates to: + /// **'No results visible for the current filters.'** + String get messageNoResultsForFilters; + + /// Empty starter packs message + /// + /// In en, this message translates to: + /// **'No starter packs yet'** + String get messageNoStarterPacksYet; + + /// Empty suggested follows message + /// + /// In en, this message translates to: + /// **'No suggestions found'** + String get messageNoSuggestionsFound; + + /// Profile context empty blocking message + /// + /// In en, this message translates to: + /// **'Not blocking anyone'** + String get messageNotBlockingAnyone; + + /// Profile context empty lists-on message + /// + /// In en, this message translates to: + /// **'Not on any lists'** + String get messageNotOnAnyLists; + + /// Fallback unavailable profile reason + /// + /// In en, this message translates to: + /// **'Profile unavailable'** + String get messageProfileUnavailable; + + /// Snackbar after profile update succeeds + /// + /// In en, this message translates to: + /// **'Profile updated'** + String get messageProfileUpdated; + + /// Report explanation text field hint + /// + /// In en, this message translates to: + /// **'Please explain why you are reporting this...'** + String get messageReportExplanationHint; + + /// Report reason description for harassment + /// + /// In en, this message translates to: + /// **'Harassment or rude behaviour'** + String get messageReportReasonHarassmentDescription; + + /// Report reason description for misleading content + /// + /// In en, this message translates to: + /// **'Misleading or deceptive content'** + String get messageReportReasonMisleadingDescription; + + /// Report reason description for other + /// + /// In en, this message translates to: + /// **'Other reason (requires explanation)'** + String get messageReportReasonOtherDescription; + + /// Report reason description for sexual content + /// + /// In en, this message translates to: + /// **'Unwanted sexual content'** + String get messageReportReasonSexualContentDescription; + + /// Report reason description for spam + /// + /// In en, this message translates to: + /// **'Spam or unsolicited content'** + String get messageReportReasonSpamDescription; + + /// Report reason description for violations + /// + /// In en, this message translates to: + /// **'Violates community guidelines'** + String get messageReportReasonViolationDescription; + + /// Profile connections search field placeholder + /// + /// In en, this message translates to: + /// **'Search handle, name, or description'** + String get messageSearchConnectionsPlaceholder; + + /// Search field placeholder when adding people + /// + /// In en, this message translates to: + /// **'Search for people to add'** + String get messageSearchPeopleToAddPlaceholder; + + /// Profile context message when some blocked accounts are unavailable + /// + /// In en, this message translates to: + /// **'Some blocked accounts are suspended or unavailable.'** + String get messageSomeBlockedAccountsUnavailable; + + /// Suggested follows unavailable message + /// + /// In en, this message translates to: + /// **'Suggested follows are unavailable right now.'** + String get messageSuggestedFollowsUnavailable; + + /// Unavailable accounts card subtitle + /// + /// In en, this message translates to: + /// **'These accounts are suspended or their public profile could not be fetched.'** + String get messageUnavailableAccountsDescription; + + /// Follow audit status label + /// + /// In en, this message translates to: + /// **'Blocked by'** + String get statusBlockedBy; + + /// Follow audit status label + /// + /// In en, this message translates to: + /// **'Blocking'** + String get statusBlocking; + + /// Follow audit status label + /// + /// In en, this message translates to: + /// **'Deactivated'** + String get statusDeactivated; + + /// Follow audit status label + /// + /// In en, this message translates to: + /// **'Deleted'** + String get statusDeleted; + + /// Follow audit status label + /// + /// In en, this message translates to: + /// **'Hidden'** + String get statusHidden; + + /// Follow audit status label + /// + /// In en, this message translates to: + /// **'Mutual block'** + String get statusMutualBlock; + + /// Follow audit status label + /// + /// In en, this message translates to: + /// **'Self-follow'** + String get statusSelfFollow; + + /// Follow audit status label + /// + /// In en, this message translates to: + /// **'Suspended'** + String get statusSuspended; + + /// Tooltip for clearing a search field + /// + /// In en, this message translates to: + /// **'Clear search'** + String get tooltipClearSearch; + + /// Tooltip for jump to top button + /// + /// In en, this message translates to: + /// **'Jump to top'** + String get tooltipJumpToTop; + + /// Profile edit validation error for invalid website + /// + /// In en, this message translates to: + /// **'Enter a valid website'** + String get validationEnterValidWebsite; + + /// Empty state message in the account switcher sheet + /// + /// In en, this message translates to: + /// **'No other signed-in accounts yet. Add an account to switch between profiles.'** + String get accountSwitcherNoOtherAccounts; + + /// Button and dialog title to add another account + /// + /// In en, this message translates to: + /// **'Add Account'** + String get buttonAddAccount; + + /// Message thread menu item to copy all messages + /// + /// In en, this message translates to: + /// **'Copy All'** + String get buttonCopyAll; + + /// Button label to mark notifications read + /// + /// In en, this message translates to: + /// **'Mark All Read'** + String get buttonMarkAllRead; + + /// Snackbar message when adding an account fails + /// + /// In en, this message translates to: + /// **'Failed to add account'** + String get errorFailedToAddAccount; + + /// Error title when messages cannot load + /// + /// In en, this message translates to: + /// **'Failed to load messages'** + String get errorFailedToLoadMessages; + + /// Error title when notifications cannot load + /// + /// In en, this message translates to: + /// **'Failed to load notifications'** + String get errorFailedToLoadNotifications; + + /// Snackbar message when removing an account from account switcher fails + /// + /// In en, this message translates to: + /// **'Unable to remove account right now.'** + String get errorUnableToRemoveAccountNow; + + /// Two-person actor summary in grouped notifications + /// + /// In en, this message translates to: + /// **'{first} and {second}'** + String formatActorListTwo(String first, String second); + + /// Multi-person actor summary in grouped notifications + /// + /// In en, this message translates to: + /// **'{first}, {second}, and {count} others'** + String formatActorListWithOthers(String first, String second, int count); + + /// Month and day label for notification sections + /// + /// In en, this message translates to: + /// **'{month} {day}'** + String formatMonthDay(String month, int day); + + /// Confirmation dialog body when removing an account from the account switcher + /// + /// In en, this message translates to: + /// **'Remove @{handle} from this device?'** + String formatRemoveAccountContent(String handle); + + /// Account switcher sheet title + /// + /// In en, this message translates to: + /// **'Accounts'** + String get labelAccounts; + + /// Alerts screen title + /// + /// In en, this message translates to: + /// **'Alerts'** + String get labelAlertsTitle; + + /// Fallback conversation title + /// + /// In en, this message translates to: + /// **'Conversation'** + String get labelConversation; + + /// Notification channel name for follows + /// + /// In en, this message translates to: + /// **'Follows'** + String get labelFollows; + + /// Notification channel name for likes + /// + /// In en, this message translates to: + /// **'Likes'** + String get labelLikes; + + /// Message requests tab label + /// + /// In en, this message translates to: + /// **'Requests'** + String get labelMessageRequests; + + /// Other notification channel name + /// + /// In en, this message translates to: + /// **'Other'** + String get labelOther; + + /// Primary messages tab label + /// + /// In en, this message translates to: + /// **'Primary'** + String get labelPrimary; + + /// Fallback actor summary for grouped notifications + /// + /// In en, this message translates to: + /// **'Someone'** + String get labelSomeone; + + /// Snackbar message after copying a message + /// + /// In en, this message translates to: + /// **'Message copied'** + String get messageCopied; + + /// Deleted message placeholder + /// + /// In en, this message translates to: + /// **'Message deleted'** + String get messageDeleted; + + /// Local notification body fallback for unknown notification reasons + /// + /// In en, this message translates to: + /// **'sent a notification'** + String get messageLocalNotificationFallbackBody; + + /// Local notification title fallback + /// + /// In en, this message translates to: + /// **'New notification'** + String get messageNewNotification; + + /// Offline state title + /// + /// In en, this message translates to: + /// **'No connection'** + String get messageNoConnection; + + /// Empty primary conversations state + /// + /// In en, this message translates to: + /// **'No conversations yet'** + String get messageNoConversationsYet; + + /// Empty message requests state + /// + /// In en, this message translates to: + /// **'No message requests'** + String get messageNoMessageRequests; + + /// Empty message thread state + /// + /// In en, this message translates to: + /// **'No messages yet'** + String get messageNoMessagesYet; + + /// Empty notifications state + /// + /// In en, this message translates to: + /// **'No notifications yet'** + String get messageNoNotificationsYet; + + /// Notification summary for contact match + /// + /// In en, this message translates to: + /// **'joined from your contacts'** + String get messageNotificationContactMatch; + + /// Notification summary for follows + /// + /// In en, this message translates to: + /// **'followed you'** + String get messageNotificationFollow; + + /// Notification summary fallback + /// + /// In en, this message translates to: + /// **'interacted with you'** + String get messageNotificationInteracted; + + /// Notification summary for likes + /// + /// In en, this message translates to: + /// **'liked your post'** + String get messageNotificationLike; + + /// Notification summary for likes via repost + /// + /// In en, this message translates to: + /// **'liked your repost'** + String get messageNotificationLikeViaRepost; + + /// Notification summary for mentions + /// + /// In en, this message translates to: + /// **'mentioned you'** + String get messageNotificationMention; + + /// Notification summary for quotes + /// + /// In en, this message translates to: + /// **'quoted your post'** + String get messageNotificationQuote; + + /// Notification summary for replies + /// + /// In en, this message translates to: + /// **'replied to your post'** + String get messageNotificationReply; + + /// Notification summary for reposts + /// + /// In en, this message translates to: + /// **'reposted your post'** + String get messageNotificationRepost; + + /// Notification summary for reposts via repost + /// + /// In en, this message translates to: + /// **'reposted your repost'** + String get messageNotificationRepostViaRepost; + + /// Notification summary for starter pack joins + /// + /// In en, this message translates to: + /// **'joined via your starter pack'** + String get messageNotificationStarterPackJoined; + + /// Notification summary for subscribed posts + /// + /// In en, this message translates to: + /// **'posted a new update'** + String get messageNotificationSubscribedPost; + + /// Notification summary for unverified account + /// + /// In en, this message translates to: + /// **'removed your verification'** + String get messageNotificationUnverified; + + /// Notification summary for verified account + /// + /// In en, this message translates to: + /// **'verified your account'** + String get messageNotificationVerified; + + /// Message composer placeholder + /// + /// In en, this message translates to: + /// **'Message…'** + String get messagePlaceholder; + + /// Snackbar when account switch requires reauthentication + /// + /// In en, this message translates to: + /// **'Please sign in again for that account.'** + String get messagePleaseSignInAgainForAccount; + + /// Offline message list explanation + /// + /// In en, this message translates to: + /// **'Reconnect to load messages.'** + String get messageReconnectToLoadMessages; + + /// Offline notifications explanation + /// + /// In en, this message translates to: + /// **'Reconnect to load notifications.'** + String get messageReconnectToLoadNotifications; + + /// Snackbar after copying a message thread + /// + /// In en, this message translates to: + /// **'Thread copied'** + String get messageThreadCopied; + + /// Today date section label + /// + /// In en, this message translates to: + /// **'Today'** + String get messageToday; + + /// Yesterday date section label + /// + /// In en, this message translates to: + /// **'Yesterday'** + String get messageYesterday; + + /// Handle placeholder in account switcher + /// + /// In en, this message translates to: + /// **'username.bsky.social'** + String get placeholderUsernameBskySocial; + + /// Account switcher validation error for empty handle or DID + /// + /// In en, this message translates to: + /// **'Enter a Bluesky handle or DID'** + String get validationEnterBlueskyHandleOrDid; + + /// Account switcher validation error for incomplete DID + /// + /// In en, this message translates to: + /// **'Enter a complete DID like did:plc:... or did:web:...'** + String get validationEnterCompleteDid; + + /// Account switcher validation error for invalid handle + /// + /// In en, this message translates to: + /// **'Enter a full handle like username.bsky.social'** + String get validationEnterFullHandle; + + /// Account switcher validation error for unsupported DID method + /// + /// In en, this message translates to: + /// **'Use a did:plc:... or did:web:... identifier'** + String get validationUseSupportedDid; + + /// Validation error for missing app password + /// + /// In en, this message translates to: + /// **'Enter your app password'** + String get validationEnterAppPassword; + + /// Generic add button label + /// + /// In en, this message translates to: + /// **'Add'** + String get buttonAdd; + + /// Button label while an add action is in progress + /// + /// In en, this message translates to: + /// **'Adding...'** + String get buttonAdding; + + /// Devtools button label linking to pds.ls + /// + /// In en, this message translates to: + /// **'Inspired by pds.ls'** + String get buttonInspiredByPdsLs; + + /// Button label to copy record JSON + /// + /// In en, this message translates to: + /// **'Copy JSON'** + String get buttonCopyJson; + + /// Button label to resolve a handle, DID, or AT URI + /// + /// In en, this message translates to: + /// **'Resolve'** + String get buttonResolve; + + /// Button label to unsubscribe from a labeler + /// + /// In en, this message translates to: + /// **'Unsubscribe'** + String get buttonUnsubscribe; + + /// Dialog title for adding a moderation labeler + /// + /// In en, this message translates to: + /// **'Add labeler'** + String get dialogAddLabelerTitle; + + /// Confirmation dialog body before clearing all log files + /// + /// In en, this message translates to: + /// **'This will permanently delete all log files. This action cannot be undone.'** + String get dialogClearAllLogsContent; + + /// Confirmation dialog title before clearing all log files + /// + /// In en, this message translates to: + /// **'Clear all logs?'** + String get dialogClearAllLogsTitle; + + /// Error title when logs cannot load + /// + /// In en, this message translates to: + /// **'Failed to load logs'** + String get errorFailedToLoadLogs; + + /// Error title when moderation settings cannot load + /// + /// In en, this message translates to: + /// **'Failed to load moderation settings'** + String get errorFailedToLoadModerationSettings; + + /// Snackbar message when unsubscribing from a labeler fails + /// + /// In en, this message translates to: + /// **'Failed to unsubscribe: {error}'** + String errorFailedToUnsubscribeLabeler(Object error); + + /// Snackbar message when adult content preference update fails + /// + /// In en, this message translates to: + /// **'Failed to update adult content: {error}'** + String errorFailedToUpdateAdultContent(Object error); + + /// Snackbar message when a label preference update fails + /// + /// In en, this message translates to: + /// **'Failed to update preference: {error}'** + String errorFailedToUpdateLabelPreference(Object error); + + /// Snackbar message when a labeler subscription update fails + /// + /// In en, this message translates to: + /// **'Failed to update subscription: {error}'** + String errorFailedToUpdateLabelerSubscription(Object error); + + /// Validation error when adding a labeler without a DID + /// + /// In en, this message translates to: + /// **'Enter a labeler DID.'** + String get errorLabelerDidRequired; + + /// Error thrown when a labeler DID cannot be loaded + /// + /// In en, this message translates to: + /// **'Labeler not found.'** + String get errorLabelerNotFound; + + /// Validation error when no labeler exists for the entered DID + /// + /// In en, this message translates to: + /// **'No labeler found for that DID.'** + String get errorNoLabelerFoundForDid; + + /// Error title when a labeler detail screen cannot load + /// + /// In en, this message translates to: + /// **'Unable to load labeler'** + String get errorUnableToLoadLabeler; + + /// Add labeler button showing current and maximum custom labelers + /// + /// In en, this message translates to: + /// **'Add ({current}/{max})'** + String formatAddLabelerLimit(int current, int max); + + /// Devtools repository collection count + /// + /// In en, this message translates to: + /// **'{count, plural, =1{1 collection} other{{count} collections}}'** + String formatCollectionsCount(int count); + + /// Devtools record CID label + /// + /// In en, this message translates to: + /// **'CID: {cid}'** + String formatCid(String cid); + + /// Moderation labeler detail chip showing custom label count + /// + /// In en, this message translates to: + /// **'{count, plural, =1{1 custom label} other{{count} custom labels}}'** + String formatCustomLabelCount(int count); + + /// Moderation labeler card chip showing custom definition count + /// + /// In en, this message translates to: + /// **'{count, plural, =1{1 definition} other{{count} definitions}}'** + String formatDefinitionCount(int count); + + /// Devtools record list count while total is unknown + /// + /// In en, this message translates to: + /// **'{count} loaded'** + String formatLoadedRecordsCount(int count); + + /// Devtools record list count showing loaded and total records + /// + /// In en, this message translates to: + /// **'{loaded} of {total}'** + String formatLoadedRecordsOfTotal(int loaded, int total); + + /// Tooltip description for a moderation label source + /// + /// In en, this message translates to: + /// **'{source} label'** + String formatModerationSourceLabel(String source); + + /// Moderation policy chip showing the blur behavior + /// + /// In en, this message translates to: + /// **'Blur {value}'** + String formatPolicyBlur(String value); + + /// Moderation policy chip showing default preference + /// + /// In en, this message translates to: + /// **'Default {value}'** + String formatPolicyDefault(String value); + + /// Moderation policy chip showing a label identifier + /// + /// In en, this message translates to: + /// **'ID {identifier}'** + String formatPolicyId(String identifier); + + /// Moderation policy chip showing severity + /// + /// In en, this message translates to: + /// **'Severity {value}'** + String formatPolicySeverity(String value); + + /// Moderation labeler card chip showing published value count + /// + /// In en, this message translates to: + /// **'{count, plural, =1{1 published value} other{{count} published values}}'** + String formatPublishedValueCount(int count); + + /// Devtools repository record count + /// + /// In en, this message translates to: + /// **'{count, plural, =1{1 record} other{{count} records}}'** + String formatRecordsCount(int count); + + /// Snackbar after subscribing to a labeler + /// + /// In en, this message translates to: + /// **'Subscribed to {name}'** + String formatSubscribedToLabeler(String name); + + /// Moderation settings adult content switch label + /// + /// In en, this message translates to: + /// **'Adult content'** + String get labelAdultContentSetting; + + /// Short chip label for adult-only moderation labels + /// + /// In en, this message translates to: + /// **'18+'** + String get labelAdultOnlyShort; + + /// Status label for always-on moderation labeler + /// + /// In en, this message translates to: + /// **'Always on'** + String get labelAlwaysOn; + + /// Log viewer auto-scroll control label + /// + /// In en, this message translates to: + /// **'Auto-scroll'** + String get labelAutoScroll; + + /// Built-in Bluesky moderation labeler title + /// + /// In en, this message translates to: + /// **'Bluesky moderation'** + String get labelBlueskyModeration; + + /// Chip label for a built-in item + /// + /// In en, this message translates to: + /// **'Built-in'** + String get labelBuiltIn; + + /// Moderation settings section title for built-in labeler + /// + /// In en, this message translates to: + /// **'Built-in labeler'** + String get labelBuiltInLabeler; + + /// Moderation labeler chip and switch label for built-in moderation + /// + /// In en, this message translates to: + /// **'Built-in moderation'** + String get labelBuiltInModeration; + + /// Moderation badge for blocked account content + /// + /// In en, this message translates to: + /// **'Blocked account'** + String get labelBlockedAccount; + + /// Moderation badge for content from an account that blocked the user + /// + /// In en, this message translates to: + /// **'Blocked by account'** + String get labelBlockedByAccount; + + /// Moderation badge for content limited by a block relationship + /// + /// In en, this message translates to: + /// **'Blocked relationship'** + String get labelBlockedRelationship; + + /// Devtools section heading for repository collections + /// + /// In en, this message translates to: + /// **'COLLECTIONS'** + String get labelCollections; + + /// Moderation label preference option to hide matching content + /// + /// In en, this message translates to: + /// **'Hide'** + String get labelContentPreferenceHide; + + /// Moderation label preference option to ignore matching content + /// + /// In en, this message translates to: + /// **'Ignore'** + String get labelContentPreferenceIgnore; + + /// Moderation label preference option to warn on matching content + /// + /// In en, this message translates to: + /// **'Warn'** + String get labelContentPreferenceWarn; + + /// Moderation settings section title for custom labelers + /// + /// In en, this message translates to: + /// **'Custom labelers'** + String get labelCustomLabelers; + + /// Labeler detail screen app bar title + /// + /// In en, this message translates to: + /// **'Labeler'** + String get labelLabeler; + + /// Input label for labeler DID + /// + /// In en, this message translates to: + /// **'Labeler DID'** + String get labelLabelerDid; + + /// Moderation settings hero title + /// + /// In en, this message translates to: + /// **'Labelers and content moderation'** + String get labelLabelersAndContentModeration; + + /// Labeler detail section heading for preferences + /// + /// In en, this message translates to: + /// **'Label preferences'** + String get labelLabelPreferences; + + /// Moderation badge for hidden content + /// + /// In en, this message translates to: + /// **'Hidden content'** + String get labelHiddenContent; + + /// Log level filter chip for debug logs + /// + /// In en, this message translates to: + /// **'Debug'** + String get labelLogLevelDebug; + + /// Log level filter chip for error logs + /// + /// In en, this message translates to: + /// **'Error'** + String get labelLogLevelError; + + /// Log level filter chip for fatal logs + /// + /// In en, this message translates to: + /// **'Fatal'** + String get labelLogLevelFatal; + + /// Log level filter chip for info logs + /// + /// In en, this message translates to: + /// **'Info'** + String get labelLogLevelInfo; + + /// Log level filter chip for trace logs + /// + /// In en, this message translates to: + /// **'Trace'** + String get labelLogLevelTrace; + + /// Log level filter chip for warning logs + /// + /// In en, this message translates to: + /// **'Warning'** + String get labelLogLevelWarning; + + /// Fallback informational moderation badge label + /// + /// In en, this message translates to: + /// **'Moderation note'** + String get labelModerationNote; + + /// Moderation source label for the built-in Bluesky labeler + /// + /// In en, this message translates to: + /// **'Bluesky'** + String get labelModerationSourceBluesky; + + /// Moderation source label for a subscribed custom labeler + /// + /// In en, this message translates to: + /// **'Subscribed labeler'** + String get labelModerationSourceSubscribedLabeler; + + /// Moderation badge for muted account content + /// + /// In en, this message translates to: + /// **'Muted account'** + String get labelMutedAccount; + + /// Moderation badge for muted phrase content + /// + /// In en, this message translates to: + /// **'Muted phrase'** + String get labelMutedPhrase; + + /// Empty state title when a labeler has no localized custom definitions + /// + /// In en, this message translates to: + /// **'No custom label definitions'** + String get labelNoCustomLabelDefinitions; + + /// Empty state title when no custom labelers are subscribed + /// + /// In en, this message translates to: + /// **'No custom labelers'** + String get labelNoCustomLabelers; + + /// Developer PDS Explorer screen title + /// + /// In en, this message translates to: + /// **'PDS Explorer'** + String get labelPdsExplorer; + + /// Labeler detail section heading for published policies + /// + /// In en, this message translates to: + /// **'Published policies'** + String get labelPublishedPolicies; + + /// Devtools breadcrumb label for an empty record key + /// + /// In en, this message translates to: + /// **'Record JSON'** + String get labelRecordJson; + + /// Generic refresh tooltip label + /// + /// In en, this message translates to: + /// **'Refresh'** + String get labelRefresh; + + /// Devtools breadcrumb fallback label for repository + /// + /// In en, this message translates to: + /// **'Repository'** + String get labelRepository; + + /// Labeler detail switch label for subscribed labelers + /// + /// In en, this message translates to: + /// **'Subscribed'** + String get labelSubscribed; + + /// Fallback moderation label for sensitive content + /// + /// In en, this message translates to: + /// **'Sensitive content'** + String get labelSensitiveContent; + + /// Generic fallback label when a name is unknown + /// + /// In en, this message translates to: + /// **'Unknown'** + String get labelUnknown; + + /// Helper text in add labeler dialog + /// + /// In en, this message translates to: + /// **'Paste a labeler DID to review and subscribe to its labels.'** + String get messageAddLabelerDidHelper; + + /// Moderation settings adult content switch helper text + /// + /// In en, this message translates to: + /// **'Required before 18+ label preferences can be changed.'** + String get messageAdultContentRequiredForLabels; + + /// Moderation settings fallback subtitle when built-in labeler details are unavailable + /// + /// In en, this message translates to: + /// **'The built-in labeler is active even if its details cannot be loaded right now.'** + String get messageBuiltInLabelerActiveWhenUnavailable; + + /// Labeler detail subtitle for built-in labeler subscription switch + /// + /// In en, this message translates to: + /// **'This labeler is always active.'** + String get messageBuiltInLabelerAlwaysActive; + + /// Developer PDS Explorer empty state helper text + /// + /// In en, this message translates to: + /// **'Enter a handle, DID, or AT-URI to explore\na user\'\'s repository.'** + String get messageDevtoolsEmptyState; + + /// Helper text shown when adult content is required to edit a label preference + /// + /// In en, this message translates to: + /// **'Enable adult content to change this 18+ label.'** + String get messageEnableAdultContentForLabel; + + /// Tooltip for blocked account moderation badge + /// + /// In en, this message translates to: + /// **'This account is blocked'** + String get messageBlockedAccountDescription; + + /// Tooltip for blocked-by account moderation badge + /// + /// In en, this message translates to: + /// **'This account has blocked you'** + String get messageBlockedByAccountDescription; + + /// Tooltip for blocked relationship moderation badge + /// + /// In en, this message translates to: + /// **'This content is limited by a block relationship'** + String get messageBlockedRelationshipDescription; + + /// Tooltip for hidden content moderation badge + /// + /// In en, this message translates to: + /// **'This content is hidden by moderation rules'** + String get messageHiddenContentDescription; + + /// Snackbar after copying record JSON + /// + /// In en, this message translates to: + /// **'JSON copied to clipboard'** + String get messageJsonCopiedToClipboard; + + /// Log viewer empty state title + /// + /// In en, this message translates to: + /// **'No logs yet'** + String get messageLogsEmpty; + + /// Log viewer empty state subtitle + /// + /// In en, this message translates to: + /// **'Log entries will appear here'** + String get messageLogsEmptySubtitle; + + /// Moderation settings hero subtitle + /// + /// In en, this message translates to: + /// **'Manage adult-content visibility, subscribed labelers, and the rules each labeler applies to posts and profiles.'** + String get messageModerationSettingsHeroSubtitle; + + /// Fallback moderation badge tooltip + /// + /// In en, this message translates to: + /// **'Moderation guidance applies here'** + String get messageModerationGuidanceApplies; + + /// Tooltip for muted account moderation badge + /// + /// In en, this message translates to: + /// **'Muted content is being downranked here'** + String get messageMutedAccountDescription; + + /// Tooltip for muted phrase moderation badge + /// + /// In en, this message translates to: + /// **'A muted phrase matched this content'** + String get messageMutedPhraseDescription; + + /// Labeler detail empty state subtitle when no localized custom definitions are available + /// + /// In en, this message translates to: + /// **'This labeler publishes values, but not localized custom definitions.'** + String get messageNoCustomLabelDefinitions; + + /// Moderation settings empty state subtitle when no custom labelers are subscribed + /// + /// In en, this message translates to: + /// **'Add a labeler DID to subscribe and configure its custom labels.'** + String get messageNoCustomLabelers; + + /// Fallback description for a label definition without localized description + /// + /// In en, this message translates to: + /// **'No description available for this label.'** + String get messageNoLabelDescriptionAvailable; + + /// Snackbar when there is no log file to share + /// + /// In en, this message translates to: + /// **'No log file available'** + String get messageNoLogFileAvailable; + + /// Devtools repository count status when record counts cannot be loaded + /// + /// In en, this message translates to: + /// **'Record counts unavailable'** + String get messageRecordCountsUnavailable; + + /// Devtools repository count status while record counts are loading + /// + /// In en, this message translates to: + /// **'Counting records...'** + String get messageRecordCountsLoading; + + /// Labeler detail subtitle for custom labeler subscription switch + /// + /// In en, this message translates to: + /// **'Subscribed labelers are added to your moderation headers and preferences.'** + String get messageSubscribedLabelersHeaders; + + /// Snackbar when the log share sheet cannot open + /// + /// In en, this message translates to: + /// **'Unable to open share sheet. Please try again.'** + String get messageUnableToOpenShareSheet; + + /// Devtools search input placeholder + /// + /// In en, this message translates to: + /// **'Handle, DID, or at:// URI'** + String get placeholderHandleDidOrAtUri; + + /// Placeholder DID in add labeler dialog + /// + /// In en, this message translates to: + /// **'did:plc:examplelabeler'** + String get placeholderLabelerDid; + + /// Log viewer search field placeholder + /// + /// In en, this message translates to: + /// **'Filter logs...'** + String get placeholderLogsFilter; + + /// Share sheet subject for log files + /// + /// In en, this message translates to: + /// **'Lazurite logs'** + String get subjectLazuriteLogs; + + /// Tooltip for clearing all log files + /// + /// In en, this message translates to: + /// **'Clear all logs'** + String get tooltipClearAllLogs; + + /// Tooltip for opening pds.ls + /// + /// In en, this message translates to: + /// **'Go to pds.ls'** + String get tooltipGoToPdsLs; + + /// Tooltip for sharing a log file + /// + /// In en, this message translates to: + /// **'Share log file'** + String get tooltipShareLogFile; } class _AppLocalizationsDelegate extends LocalizationsDelegate { diff --git a/lib/core/l10n/app_localizations_en.dart b/lib/core/l10n/app_localizations_en.dart index f3aaa09..8109773 100644 --- a/lib/core/l10n/app_localizations_en.dart +++ b/lib/core/l10n/app_localizations_en.dart @@ -17,12 +17,30 @@ class AppLocalizationsEn extends AppLocalizations { @override String get buttonCancel => 'Cancel'; + @override + String get buttonCompose => 'Compose'; + @override String get buttonClearCache => 'Clear Cache'; @override String get buttonContinue => 'Continue'; + @override + String get buttonClearLocal => 'Clear Local'; + + @override + String get buttonDelete => 'Delete'; + + @override + String get buttonDiscard => 'Discard'; + + @override + String get buttonLoadMoreQuotes => 'Load more quotes'; + + @override + String get buttonLoadMoreReposts => 'Load more reposts'; + @override String get buttonRemove => 'Remove'; @@ -38,18 +56,33 @@ class AppLocalizationsEn extends AppLocalizations { @override String get buttonOpen => 'Open'; + @override + String get buttonOk => 'OK'; + + @override + String get buttonPost => 'Post'; + @override String get buttonResetSignInData => 'Reset Sign-In Data'; @override String get buttonRetry => 'Retry'; + @override + String get buttonSave => 'Save'; + + @override + String get buttonSaveChanges => 'Save Changes'; + @override String get buttonSignIn => 'Sign In'; @override String get buttonShowContent => 'Show content'; + @override + String get buttonShare => 'Share'; + @override String get buttonTryAgain => 'Try again'; @@ -59,12 +92,21 @@ class AppLocalizationsEn extends AppLocalizations { @override String get commonNone => 'None'; + @override + String get commonNow => 'now'; + + @override + String get commonJustNow => 'Just now'; + @override String get commonNotCheckedYet => 'Not checked yet'; @override String get commonOff => 'Off'; + @override + String get commonUnknown => 'unknown'; + @override String get dialogClearCacheContent => 'This removes cached posts, profiles, images, feeds, threads, label data, and local semantic search data.\n\nAccounts, settings, drafts, bookmarks, and likes are kept.'; @@ -72,6 +114,41 @@ class AppLocalizationsEn extends AppLocalizations { @override String get dialogClearCacheTitle => 'Clear cache?'; + @override + String get dialogClearLocalBookmarksContent => + 'This removes only local bookmarks from this device. Bluesky cloud bookmarks will not be deleted.'; + + @override + String get dialogClearLocalBookmarksTitle => 'Clear local bookmarks?'; + + @override + String get dialogDeletePostContent => 'This action cannot be undone.'; + + @override + String get dialogDeletePostTitle => 'Delete Post?'; + + @override + String get dialogDeleteDraftTitle => 'Delete Draft?'; + + @override + String get dialogDiscardChangesContent => 'You have unsaved edits. Discard them and leave?'; + + @override + String get dialogDiscardChangesTitle => 'Discard Changes?'; + + @override + String get dialogEditAlgorithmContent => + 'Lazurite saves edits by deleting and recreating the post record with the same URI. During re-indexing, ranking, counters, and search visibility can shift, and updates may take time to appear everywhere.'; + + @override + String get dialogEditAlgorithmTitle => 'How Post Editing Works'; + + @override + String get dialogSaveDraftContent => 'You have unsaved content. Would you like to save it as a draft?'; + + @override + String get dialogSaveDraftTitle => 'Save Draft?'; + @override String dialogRemoveAccountContent(String handle) { return 'Remove @$handle from this device?'; @@ -107,6 +184,33 @@ class AppLocalizationsEn extends AppLocalizations { @override String get errorGenericTitle => 'Something went wrong'; + @override + String get errorFailedToLoadBookmarks => 'Failed to load bookmarks'; + + @override + String get errorFailedToLoadLikedPosts => 'Failed to load liked posts'; + + @override + String errorFailedToLoadLikedPostsDetails(Object error) { + return 'Failed to load liked posts: $error'; + } + + @override + String errorFailedToRefreshLikedPosts(Object error) { + return 'Failed to refresh liked posts: $error'; + } + + @override + String get errorFailedToLoadTrending => 'Failed to load trending'; + + @override + String errorFailedToLoadTrendingTopics(Object error) { + return 'Failed to load trending topics: $error'; + } + + @override + String get errorUnknown => 'Unknown error'; + @override String get errorUnableToRemoveAccount => 'Unable to remove account right now.'; @@ -146,6 +250,117 @@ class AppLocalizationsEn extends AppLocalizations { return 'Depth $depth'; } + @override + String formatLikedOn(String date) { + return 'Liked on $date'; + } + + @override + String formatLikesCount(int count) { + String _temp0 = intl.Intl.pluralLogic(count, locale: localeName, other: '$count Likes', one: '1 Like'); + return '$_temp0'; + } + + @override + String formatOfflineReconnectAction(String action) { + return 'You are offline. Reconnect to $action.'; + } + + @override + String formatReplyingToHandle(String handle) { + return 'Replying to @$handle'; + } + + @override + String formatRepostsCount(int count) { + String _temp0 = intl.Intl.pluralLogic(count, locale: localeName, other: '$count Reposts', one: '1 Repost'); + return '$_temp0'; + } + + @override + String formatSavedOn(String date) { + return 'Saved on $date'; + } + + @override + String formatTrendingCategory(String category) { + return 'Category: $category'; + } + + @override + String formatTrendingPostCount(int count) { + String _temp0 = intl.Intl.pluralLogic(count, locale: localeName, other: '$count posts', one: '1 post'); + return '$_temp0'; + } + + @override + String formatViewHandle(String handle) { + return 'View @$handle'; + } + + @override + String formatComposeFailedToPickImage(Object error) { + return 'Failed to pick image: $error'; + } + + @override + String formatComposeFailedToPickVideo(Object error) { + return 'Failed to pick video: $error'; + } + + @override + String formatComposeFailedToSaveChanges(Object error) { + return 'Failed to save changes: $error'; + } + + @override + String formatComposeFailedToSubmitPost(Object error) { + return 'Failed to submit post: $error'; + } + + @override + String formatComposeImageTooLarge(String fileName, String sizeMb) { + return 'Image \"$fileName\" is $sizeMb MB - max 1 MB.'; + } + + @override + String formatComposeQuotingHandle(String handle) { + return 'Quoting @$handle'; + } + + @override + String formatComposeScheduledFor(String dateTime) { + return 'Scheduled for $dateTime'; + } + + @override + String formatComposeVideoReadyWithAltText(String altText) { + return 'Ready - \"$altText\"'; + } + + @override + String formatComposeVideoTooLarge(String sizeMb) { + return 'Video is $sizeMb MB - exceeds the 100 MB limit.'; + } + + @override + String formatDraftCount(int count) { + String _temp0 = intl.Intl.pluralLogic(count, locale: localeName, other: '$count drafts', one: '1 draft'); + return '$_temp0'; + } + + @override + String get actionLikeThisPost => 'like this post'; + + @override + String get actionReplyToThisPost => 'reply to this post'; + + @override + String get actionRepostThisPost => 'repost this post'; + + @override + String get actionPublishYourPost => 'publish your post'; + @override String get labelAbout => 'About'; @@ -197,6 +412,21 @@ class AppLocalizationsEn extends AppLocalizations { @override String get labelBookmarksAndLikes => 'Bookmarks & Likes'; + @override + String get labelBookmarkActions => 'Bookmark actions'; + + @override + String get labelBookmarkedPost => 'Bookmarked Post'; + + @override + String get labelBookmarks => 'Bookmarks'; + + @override + String get labelBluesky => 'Bluesky'; + + @override + String get labelAlt => 'ALT'; + @override String get labelCacheCleared => 'Cache cleared'; @@ -206,6 +436,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get labelCleanFollows => 'Clean Follows'; + @override + String get labelClose => 'Close'; + @override String get labelClearCache => 'Clear Cache'; @@ -302,6 +535,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get labelNewPost => 'New Post'; + @override + String get labelNow => 'NOW'; + @override String get labelNotifications => 'Notifications'; @@ -392,6 +628,81 @@ class AppLocalizationsEn extends AppLocalizations { @override String get labelClearUntil => 'Clear until'; + @override + String get labelClearLocalBookmarks => 'Clear local bookmarks'; + + @override + String get labelCopyLink => 'Copy Link'; + + @override + String get labelDeletePost => 'Delete Post'; + + @override + String get labelDeleteDraft => 'Delete draft'; + + @override + String get labelEditPost => 'Edit Post'; + + @override + String get labelLiked => 'Liked'; + + @override + String get labelLikedBy => 'LIKED BY'; + + @override + String get labelLikedPost => 'Liked Post'; + + @override + String get labelMoreInfo => 'More info'; + + @override + String get labelLocal => 'Local'; + + @override + String get labelOpenPost => 'Open post'; + + @override + String get labelQuotePost => 'Quote Post'; + + @override + String get labelQuoteReposts => 'QUOTE / REPOSTS'; + + @override + String get labelQuotes => 'Quotes'; + + @override + String get labelRemoveFromBluesky => 'Remove from Bluesky'; + + @override + String get labelRemoveLocalSave => 'Remove local save'; + + @override + String get labelReportPost => 'Report Post'; + + @override + String get labelRepost => 'Repost'; + + @override + String get labelRepostedBy => 'REPOSTED BY'; + + @override + String get labelReposts => 'Reposts'; + + @override + String get labelSaveImage => 'Save image'; + + @override + String get labelSaveLocally => 'Save locally'; + + @override + String get labelSaveToBluesky => 'Save to Bluesky'; + + @override + String get labelSchedule => 'Schedule'; + + @override + String get labelScheduled => 'Scheduled'; + @override String get labelSavedAccounts => 'Saved accounts'; @@ -401,6 +712,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get labelSemanticSearch => 'Semantic Search'; + @override + String get labelShowLikedUsers => 'Show Liked Users'; + + @override + String get labelShowQuoteRepostList => 'Show Quote/Repost List'; + @override String get labelSettings => 'Settings'; @@ -431,6 +748,24 @@ class AppLocalizationsEn extends AppLocalizations { @override String get labelTroubleshooting => 'Troubleshooting'; + @override + String get labelVideo => 'Video'; + + @override + String get labelTopics => 'Topics'; + + @override + String get labelTrending => 'Trending'; + + @override + String get labelSuggested => 'Suggested'; + + @override + String get labelSuggestedFollows => 'Suggested Follows'; + + @override + String get labelUnrepost => 'Unrepost'; + @override String get labelTypeaheadProvider => 'Typeahead Provider'; @@ -500,6 +835,12 @@ class AppLocalizationsEn extends AppLocalizations { @override String get messageFeedsSubtitle => 'Manage pinned and saved feeds'; + @override + String get messageLinkCopiedToClipboard => 'Link copied to clipboard'; + + @override + String get messageLoadingTrendingTopics => 'Loading trending topics'; + @override String get messageForceNextXrpc401Subtitle => 'Debug-only: next network request returns Unauthorized to test token refresh'; @@ -507,6 +848,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get messageManageSemanticSearchSubtitle => 'Manage semantic search from Bookmarks & Likes -> Search'; + @override + String get messageMetadataTemporarilyUnavailable => 'Metadata temporarily unavailable'; + @override String get messageModeratedContentCannotReveal => 'Hidden by your moderation settings and cannot be revealed here.'; @@ -518,77 +862,1506 @@ class AppLocalizationsEn extends AppLocalizations { 'Moderation/ranking can differ by provider. Verify health and recent fallback state.'; @override - String get messageRefreshProviderHealthSubtitle => 'Probe public AppView endpoints now'; + String get messageLikedPostsUnavailable => 'Liked posts are unavailable right now.'; @override - String get messageResetSignInDataSubtitle => - 'Troubleshoot OAuth or account-switching issues by clearing local sessions on this device'; + String get messageChangesSaved => 'Changes saved.'; @override - String get messageSearchSubtitle => 'Search'; + String get messageComposeAddAltText => 'Add alt text'; @override - String get messageClearSearchHistoryContent => 'This will delete all your recent searches.'; + String get messageComposeAddImage => 'Add image'; @override - String get messageClearSearchHistoryTitle => 'Clear search history?'; + String get messageComposeAddVideo => 'Add video'; @override - String get messageStartTypingToSearchHandles => 'Start typing to search handles.'; + String get messageComposeClearScheduledTime => 'Clear scheduled time'; @override - String get messageStarterPackSearchApiUnavailable => 'Starter pack search is not available in the API yet.'; + String get messageComposeDescribeImage => 'Describe the image'; @override - String get messageStarterPackSearchUnavailableTitle => 'Starter Pack Search Is Unavailable'; + String get messageComposeDescribeVideo => 'Describe the video'; @override - String get messageStarterPackSearchUnavailableBody => - '(Starter Pack Search is not yet implemented in the BlueSky API)'; + String get messageComposeDraftSaved => 'Draft saved'; @override - String get messageTrackApiProgress => 'Track API progress'; + String get messageComposeDrafts => 'Drafts'; @override - String get messageCouldNotOpenIssueLink => 'Could not open issue link.'; + String get messageComposeEditNotice => + 'Edits are saved by replacing the record while keeping this post URI. Ranking, counts, and visibility may shift while networks re-index.'; @override - String get messageSearchPostsPlaceholder => 'Search posts'; + String get messageComposeImageAltTextTitle => 'Alt text'; @override - String get messageSearchThisProfilesPostsPlaceholder => 'Search this profile\'s posts'; + String get messageComposeImageMaxCount => 'Maximum 4 images allowed'; @override - String get messageSearchPeoplePlaceholder => 'Search people'; + String get messageComposeImageMustBeJpegPngWebp => 'Image must be JPEG, PNG, or WebP'; @override - String get messageSearchFeedsPlaceholder => 'Search feeds'; + String get messageComposeImageMustBeUnder1Mb => 'Image must be smaller than 1MB'; @override - String get messageStarterPackSearchUnavailablePlaceholder => 'Starter pack search unavailable'; + String get messageComposeNoDraftsSaved => 'No drafts saved'; @override - String get messageSlingshotIdentityFallbackSubtitle => - 'If handle lookup fails, use Slingshot to find your DID and PDS so sign-in can continue'; + String get messageComposeNoText => '(No text)'; @override - String get messageThreadAutoCollapseSubtitle => 'Collapse reply branches deeper than the selected level'; + String get messageComposePlaceholder => 'What\'s on your mind?'; @override - String get messageTurnOffNonEssentialMotion => 'Turn off non-essential motion effects'; + String get messageComposePreviewUnavailable => 'Preview unavailable'; @override - String get messageVideoUploadLimitsSubtitle => 'Check your daily video quota'; + String get messageComposeQuotingPost => 'Quoting post'; @override - String get placeholderAppPassword => 'xxxx-xxxx-xxxx-xxxx'; + String get messageComposeRemoveExistingMediaBeforeVideo => 'Remove existing media before adding a video'; @override - String get placeholderHandleOrDid => 'username.bsky.social or did:plc:...'; + String get messageComposeRemoveImage => 'Remove image'; @override - String get promptHandleOrDid => 'Handle or DID'; + String get messageComposeRemoveQuotedPost => 'Remove quoted post'; @override - String get validationEnterAppPassword => 'Enter your app password'; + String get messageComposeSaveDraft => 'Save draft'; + + @override + String get messageComposeVideoAltTextTitle => 'Video alt text'; + + @override + String get messageVideoCheckingUploadLimits => 'Checking upload limits...'; + + @override + String get messageVideoDailyUploadLimitReached => 'Daily video upload limit reached.'; + + @override + String get messageVideoProcessing => 'Processing...'; + + @override + String get messageVideoProcessingFailed => 'Video processing failed.'; + + @override + String get messageVideoProcessingTimedOut => 'Video processing timed out.'; + + @override + String get messageVideoReady => 'Ready'; + + @override + String get messageVideoReadyToUpload => 'Ready to upload'; + + @override + String get messageVideoUploadFailed => 'Upload failed - please try again.'; + + @override + String get messageVideoUploading => 'Uploading...'; + + @override + String get errorComposeChangedElsewhere => 'This post was changed elsewhere. Reopen it and try editing again.'; + + @override + String get errorComposeCouldNotConfirmEdit => + 'Edit was submitted but could not be confirmed yet. Please reopen the post and verify.'; + + @override + String get errorComposeCouldNotSaveAndConfirmRecovery => + 'Could not save changes and we could not confirm recovery. Reopen the thread and verify the post.'; + + @override + String get errorComposeEditContextMissing => 'Edit context is missing. Please reopen the editor and try again.'; + + @override + String get errorComposeFailedToCreatePost => 'Failed to create post. Please try again.'; + + @override + String get errorComposeFailedToSaveChanges => 'Failed to save changes. Please try again.'; + + @override + String get errorComposeFailedToUploadImage => 'Failed to upload image. Please try again.'; + + @override + String get errorComposeImageFileNotFound => 'Image file not found. Please re-attach and try again.'; + + @override + String get errorComposeNetworkSavedAsDraft => 'Network error - post saved as draft.'; + + @override + String get errorComposeOriginalPostRestored => 'Could not save changes. Your original post was restored.'; + + @override + String get errorComposeUnsupportedImageFormat => 'Unsupported image format. Use JPEG, PNG, or WebP.'; + + @override + String get messageNoBookmarks => 'No bookmarks'; + + @override + String get messageNoBookmarksSubtitle => 'Posts you bookmark will appear here'; + + @override + String get messageNoBookmarksInSource => 'No bookmarks in this source'; + + @override + String get messageNoBookmarksInSourceSubtitle => 'Try switching tabs or saving posts to this source'; + + @override + String get messageNoInteractionsYet => 'No interactions yet'; + + @override + String get messageNoLikedPosts => 'No liked posts'; + + @override + String get messageNoLikedPostsSubtitle => 'Posts you like will appear here after sync'; + + @override + String get messageNoLikedPostsYet => 'No liked posts yet'; + + @override + String get messageNoQuotesYet => 'No quotes yet'; + + @override + String get messageNoRepostsYet => 'No reposts yet'; + + @override + String get messageNoTrendingTopicsRightNow => 'No trending topics right now'; + + @override + String get messagePostDeleted => 'Post deleted'; + + @override + String get messageQuotePostSubtitle => 'Quote this post with your own text'; + + @override + String get messageQuotedPostBlocked => 'Quoted post is blocked'; + + @override + String get messageQuotedPostNotFound => 'Quoted post not found'; + + @override + String get messageQuotedPostUnavailable => 'Quoted post is unavailable'; + + @override + String get messageRemoveRepostSubtitle => 'Remove this repost'; + + @override + String get messageReplyInThread => 'Reply in a thread'; + + @override + String get messageReplyingTo => 'Replying to'; + + @override + String get messageShareThisPostSubtitle => 'Share this post'; + + @override + String get messageShowLikedUsersSubtitle => 'View who liked this post'; + + @override + String get messageShowQuoteRepostListSubtitle => 'View quote posts and expand reposts'; + + @override + String get messageRefreshProviderHealthSubtitle => 'Probe public AppView endpoints now'; + + @override + String get messageResetSignInDataSubtitle => + 'Troubleshoot OAuth or account-switching issues by clearing local sessions on this device'; + + @override + String get messageSearchSubtitle => 'Search'; + + @override + String get messageClearSearchHistoryContent => 'This will delete all your recent searches.'; + + @override + String get messageClearSearchHistoryTitle => 'Clear search history?'; + + @override + String get messageStartTypingToSearchHandles => 'Start typing to search handles.'; + + @override + String get messageStarterPackSearchApiUnavailable => 'Starter pack search is not available in the API yet.'; + + @override + String get messageStarterPackSearchUnavailableTitle => 'Starter Pack Search Is Unavailable'; + + @override + String get messageStarterPackSearchUnavailableBody => + '(Starter Pack Search is not yet implemented in the BlueSky API)'; + + @override + String get messageTrackApiProgress => 'Track API progress'; + + @override + String get messageCouldNotOpenIssueLink => 'Could not open issue link.'; + + @override + String get messageSearchPostsPlaceholder => 'Search posts'; + + @override + String get messageSearchThisProfilesPostsPlaceholder => 'Search this profile\'s posts'; + + @override + String get messageSearchPeoplePlaceholder => 'Search people'; + + @override + String get messageSearchForPeoplePlaceholder => 'Search for people'; + + @override + String get messageSearchFeedsPlaceholder => 'Search feeds'; + + @override + String get messageStarterPackSearchUnavailablePlaceholder => 'Starter pack search unavailable'; + + @override + String get messageSlingshotIdentityFallbackSubtitle => + 'If handle lookup fails, use Slingshot to find your DID and PDS so sign-in can continue'; + + @override + String get messageThreadAutoCollapseSubtitle => 'Collapse reply branches deeper than the selected level'; + + @override + String get messageTurnOffNonEssentialMotion => 'Turn off non-essential motion effects'; + + @override + String get messageVideoUploadLimitsSubtitle => 'Check your daily video quota'; + + @override + String get placeholderAppPassword => 'xxxx-xxxx-xxxx-xxxx'; + + @override + String get placeholderHandleOrDid => 'username.bsky.social or did:plc:...'; + + @override + String get promptHandleOrDid => 'Handle or DID'; + + @override + String get buttonAddFeed => 'Add feed'; + + @override + String get buttonAddMembers => 'Add members'; + + @override + String get buttonBlock => 'Block'; + + @override + String get buttonCreate => 'Create'; + + @override + String get buttonEdit => 'Edit'; + + @override + String get buttonFollow => 'Follow'; + + @override + String get buttonFollowAll => 'Follow all'; + + @override + String get buttonFollowing => 'Following'; + + @override + String get buttonFollowingInProgress => 'Following…'; + + @override + String get buttonLoadMore => 'Load more'; + + @override + String get buttonMute => 'Mute'; + + @override + String get buttonScan => 'Scan'; + + @override + String get buttonSeeAll => 'See all'; + + @override + String get buttonShowAccounts => 'Show accounts'; + + @override + String get buttonSubmitReport => 'Submit Report'; + + @override + String get buttonUnblock => 'Unblock'; + + @override + String get buttonUnfollow => 'Unfollow'; + + @override + String buttonUnfollowSelected(int count) { + return 'Unfollow Selected ($count)'; + } + + @override + String get buttonUnmute => 'Unmute'; + + @override + String get dialogBlockAccountContent => + 'They will not be able to see your posts or interact with you. They will not be notified that you blocked them.'; + + @override + String get dialogBlockAccountTitle => 'Block Account?'; + + @override + String get dialogDeleteListTitle => 'Delete list?'; + + @override + String get dialogDeleteStarterPackContent => + 'This will permanently delete this starter pack and its backing list. This cannot be undone.'; + + @override + String get dialogDeleteStarterPackTitle => 'Delete starter pack'; + + @override + String get dialogMuteAccountContent => 'You will no longer see their posts or receive notifications from them.'; + + @override + String get dialogMuteAccountTitle => 'Mute Account?'; + + @override + String get dialogUnblockAccountContent => 'They will be able to see your posts and interact with you again.'; + + @override + String get dialogUnblockAccountTitle => 'Unblock Account?'; + + @override + String get dialogUnfollowAccountContent => 'You will no longer see their posts in your feed.'; + + @override + String get dialogUnfollowAccountTitle => 'Unfollow?'; + + @override + String get dialogUnmuteAccountContent => 'You will see their posts and receive notifications again.'; + + @override + String get dialogUnmuteAccountTitle => 'Unmute Account?'; + + @override + String get errorFailedToCreateStarterPack => 'Failed to create starter pack'; + + @override + String get errorFailedToLoadAccounts => 'Failed to load accounts'; + + @override + String get errorFailedToLoadFeed => 'Failed to load feed'; + + @override + String get errorFailedToLoadFeeds => 'Failed to load feeds'; + + @override + String get errorFailedToLoadList => 'Failed to load list'; + + @override + String get errorFailedToLoadLists => 'Failed to load lists'; + + @override + String get errorFailedToLoadMembers => 'Failed to load members'; + + @override + String get errorFailedToLoadMore => 'Failed to load more'; + + @override + String get errorFailedToLoadPosts => 'Failed to load posts'; + + @override + String get errorFailedToLoadProfile => 'Unable to load profile'; + + @override + String get errorFailedToLoadStarterPack => 'Failed to load starter pack'; + + @override + String get errorFailedToLoadStarterPacks => 'Failed to load starter packs'; + + @override + String get errorFailedToLoadSuggestions => 'Failed to load suggestions'; + + @override + String get errorFollowAuditFailed => 'Failed to complete follow audit.'; + + @override + String get errorImageTooLarge => 'Image must be smaller than 1MB'; + + @override + String get errorInvalidProfileImageType => 'Use a JPEG or PNG image'; + + @override + String get errorProfileImageReadFailed => 'Unable to read selected image'; + + @override + String get errorReportFailed => 'Unable to submit your report. Please try again later.'; + + @override + String get errorReportFailedTitle => 'Report Failed'; + + @override + String errorUnableToLoadConnections(String tab) { + return 'Unable to load $tab'; + } + + @override + String get errorUnableToUpdateProfile => 'Unable to update profile'; + + @override + String formatAccountCount(int count) { + String _temp0 = intl.Intl.pluralLogic(count, locale: localeName, other: '$count accounts', one: '1 account'); + return '$_temp0'; + } + + @override + String formatBlockedByAccountsUnavailable(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count blocked-by accounts', + one: '1 blocked-by account', + ); + return 'Found $_temp0, but public Bluesky profile details could not be loaded.'; + } + + @override + String formatConnectionsLoading(String tab) { + return 'Loading $tab...'; + } + + @override + String formatConnectionsNoMatches(String tab, String query) { + return 'No $tab match \"$query\"'; + } + + @override + String formatConnectionsNoneFound(String tab) { + return 'No $tab found'; + } + + @override + String formatConnectionsSearching(int count) { + return 'Searching $count accounts...'; + } + + @override + String formatConnectionsSearched(int count) { + return 'Searched $count accounts'; + } + + @override + String formatConnectionsSearchStopped(int count) { + return 'Search stopped after $count accounts'; + } + + @override + String formatClassifyingProgress(int progress, int total) { + return 'Classifying: $progress/$total'; + } + + @override + String get formatDidCopied => 'DID copied to clipboard'; + + @override + String formatFetchingFollowsProgress(int progress, int total) { + return 'Fetching follows: $progress/$total'; + } + + @override + String formatFollowedMemberCount(int count) { + String _temp0 = intl.Intl.pluralLogic(count, locale: localeName, other: '$count members', one: '1 member'); + return 'Followed $_temp0'; + } + + @override + String formatFollowsScanned(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count follows scanned for problematic accounts', + one: '1 follow scanned for problematic accounts', + ); + return '$_temp0'; + } + + @override + String formatHideStatus(String status) { + return 'Hide $status'; + } + + @override + String formatJoinedDate(String date) { + return 'Joined $date'; + } + + @override + String formatJoinedRelative(String relativeTime) { + return 'Joined $relativeTime'; + } + + @override + String formatListByHandle(String handle) { + return 'by @$handle'; + } + + @override + String formatMemberCount(int count) { + String _temp0 = intl.Intl.pluralLogic(count, locale: localeName, other: '$count members', one: '1 member'); + return '$_temp0'; + } + + @override + String formatProfileReportTitle(String title, String handle) { + return '$title by @$handle'; + } + + @override + String formatProfileTextLimit(String label, int count) { + return '$label must be $count characters or fewer'; + } + + @override + String formatProfileTextTooLong(String label) { + return '$label is too long'; + } + + @override + String formatProfilesFailedToLoad(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count profiles could not be loaded.', + one: '1 profile could not be loaded.', + ); + return '$_temp0'; + } + + @override + String formatReportSubmitted(String reportId) { + return 'Thank you. Your report (ID: $reportId) has been submitted.'; + } + + @override + String formatSelectedCount(int selected, int total) { + return 'Selected: $selected/$total'; + } + + @override + String formatShowStatus(String status) { + return 'Show $status'; + } + + @override + String formatUnavailableAccounts(int count) { + return 'Unavailable accounts ($count)'; + } + + @override + String formatUnfollowedAccounts(int count) { + return 'Unfollowed $count account(s)'; + } + + @override + String formatValidationRequiredMaxCharacters(int count) { + return 'Required, max $count characters'; + } + + @override + String get labelAddToList => 'Add to list'; + + @override + String get labelAuditFollowers => 'Audit Followers'; + + @override + String get labelBanner => 'Banner'; + + @override + String get labelBlockViaList => 'Block via list'; + + @override + String get labelBlockedBy => 'Blocked By'; + + @override + String get labelBlocking => 'Blocking'; + + @override + String get labelConnections => 'Connections'; + + @override + String get labelCopyDid => 'Copy DID'; + + @override + String get labelCreateList => 'Create list'; + + @override + String get labelCreateStarterPack => 'Create starter pack'; + + @override + String get labelCurateShort => 'CURATE'; + + @override + String get labelCurrentMembers => 'Current Members'; + + @override + String get labelCurationLists => 'Curation Lists'; + + @override + String get labelDescription => 'Description'; + + @override + String get labelDescriptionOptional => 'Description (optional)'; + + @override + String get labelDisplayName => 'Display name'; + + @override + String get labelEditList => 'Edit list'; + + @override + String get labelEditProfile => 'Edit profile'; + + @override + String get labelEditStarterPack => 'Edit starter pack'; + + @override + String get labelFeed => 'Feed'; + + @override + String get labelFollowers => 'Followers'; + + @override + String get labelJoinedThisWeek => 'joined this week'; + + @override + String get labelJoinedTotal => 'joined total'; + + @override + String get labelFollowing => 'Following'; + + @override + String get labelList => 'List'; + + @override + String get labelLists => 'Lists'; + + @override + String get labelMedia => 'Media'; + + @override + String get labelMembers => 'Members'; + + @override + String get labelModerationLists => 'Moderation Lists'; + + @override + String get labelModerationShort => 'MOD'; + + @override + String get labelMuteList => 'Mute list'; + + @override + String get labelMutuals => 'Mutuals'; + + @override + String get labelMyLists => 'My Lists'; + + @override + String get labelName => 'Name'; + + @override + String get labelNewStarterPack => 'New Starter Pack'; + + @override + String get labelOtherLists => 'Other Lists'; + + @override + String get labelPronouns => 'Pronouns'; + + @override + String get labelProfileContext => 'Profile Context'; + + @override + String get labelProfileTitle => 'Profile'; + + @override + String get labelRecommendedFeeds => 'Recommended Feeds'; + + @override + String get labelReferenceLists => 'Reference Lists'; + + @override + String get labelReferenceShort => 'REFERENCE'; + + @override + String get labelReplies => 'Replies'; + + @override + String get labelReport => 'Report'; + + @override + String get labelReportAccount => 'Report Account'; + + @override + String get labelReportReason => 'Reason'; + + @override + String get labelReportReasonExplanationRequired => 'Explanation (required)'; + + @override + String get labelReportReasonHarassment => 'Harassment'; + + @override + String get labelReportReasonMisleading => 'Misleading'; + + @override + String get labelReportReasonOther => 'Other'; + + @override + String get labelReportReasonSexualContent => 'Sexual Content'; + + @override + String get labelReportReasonSpam => 'Spam'; + + @override + String get labelReportReasonViolation => 'Violation'; + + @override + String get labelReportSubmitted => 'Report Submitted'; + + @override + String get labelSelectAll => 'Select All'; + + @override + String get labelSelectFeed => 'Select a feed'; + + @override + String get labelShareProfile => 'Share Profile'; + + @override + String get labelStarterPack => 'Starter Pack'; + + @override + String get labelType => 'Type'; + + @override + String get labelTotalJoined => 'total joined'; + + @override + String get labelUnavailableLikedPost => 'Unavailable liked post'; + + @override + String get labelUnblockViaList => 'Unblock via list'; + + @override + String get labelUnmuteList => 'Unmute list'; + + @override + String get labelUpToThree => '(up to 3)'; + + @override + String get labelWebsite => 'Website'; + + @override + String get labelYou => 'You'; + + @override + String get messageBlockedByContextNotice => + 'Blocks are a normal part of social media. This data is public on the AT Protocol.'; + + @override + String get messageBlockingOnlyOwnProfile => 'Blocking information is only available when viewing your own profile.'; + + @override + String get messageChangeAvatarImage => 'Change avatar image'; + + @override + String get messageChangeBannerImage => 'Change banner image'; + + @override + String get messageEnterValidWebsite => 'Enter a valid website'; + + @override + String get messageFeedUnavailableForModerationLists => 'Feed not available for moderation lists'; + + @override + String get messageFollowAuditIntro => 'Scan your follows for deleted, suspended, blocked, and hidden accounts.'; + + @override + String get messageFollowAuditStartPrompt => 'Tap Scan to audit your follow list.'; + + @override + String get messageNoAccountsBlockedThisUser => 'No accounts have blocked this user'; + + @override + String get messageNoListsYet => 'No lists yet'; + + @override + String get messageNoMembers => 'No members'; + + @override + String get messageNoMembersYet => 'No members yet'; + + @override + String get messageNoMembersYetSearch => 'No members yet. Search above to add people.'; + + @override + String get messageNoMediaPostsYet => 'No media posts yet'; + + @override + String get messageNoPostsYet => 'No posts yet'; + + @override + String get messageNoProblematicFollows => 'No problematic follows found'; + + @override + String get messageNoRepliesYet => 'No replies yet'; + + @override + String get messageNoResultsForFilters => 'No results visible for the current filters.'; + + @override + String get messageNoStarterPacksYet => 'No starter packs yet'; + + @override + String get messageNoSuggestionsFound => 'No suggestions found'; + + @override + String get messageNotBlockingAnyone => 'Not blocking anyone'; + + @override + String get messageNotOnAnyLists => 'Not on any lists'; + + @override + String get messageProfileUnavailable => 'Profile unavailable'; + + @override + String get messageProfileUpdated => 'Profile updated'; + + @override + String get messageReportExplanationHint => 'Please explain why you are reporting this...'; + + @override + String get messageReportReasonHarassmentDescription => 'Harassment or rude behaviour'; + + @override + String get messageReportReasonMisleadingDescription => 'Misleading or deceptive content'; + + @override + String get messageReportReasonOtherDescription => 'Other reason (requires explanation)'; + + @override + String get messageReportReasonSexualContentDescription => 'Unwanted sexual content'; + + @override + String get messageReportReasonSpamDescription => 'Spam or unsolicited content'; + + @override + String get messageReportReasonViolationDescription => 'Violates community guidelines'; + + @override + String get messageSearchConnectionsPlaceholder => 'Search handle, name, or description'; + + @override + String get messageSearchPeopleToAddPlaceholder => 'Search for people to add'; + + @override + String get messageSomeBlockedAccountsUnavailable => 'Some blocked accounts are suspended or unavailable.'; + + @override + String get messageSuggestedFollowsUnavailable => 'Suggested follows are unavailable right now.'; + + @override + String get messageUnavailableAccountsDescription => + 'These accounts are suspended or their public profile could not be fetched.'; + + @override + String get statusBlockedBy => 'Blocked by'; + + @override + String get statusBlocking => 'Blocking'; + + @override + String get statusDeactivated => 'Deactivated'; + + @override + String get statusDeleted => 'Deleted'; + + @override + String get statusHidden => 'Hidden'; + + @override + String get statusMutualBlock => 'Mutual block'; + + @override + String get statusSelfFollow => 'Self-follow'; + + @override + String get statusSuspended => 'Suspended'; + + @override + String get tooltipClearSearch => 'Clear search'; + + @override + String get tooltipJumpToTop => 'Jump to top'; + + @override + String get validationEnterValidWebsite => 'Enter a valid website'; + + @override + String get accountSwitcherNoOtherAccounts => + 'No other signed-in accounts yet. Add an account to switch between profiles.'; + + @override + String get buttonAddAccount => 'Add Account'; + + @override + String get buttonCopyAll => 'Copy All'; + + @override + String get buttonMarkAllRead => 'Mark All Read'; + + @override + String get errorFailedToAddAccount => 'Failed to add account'; + + @override + String get errorFailedToLoadMessages => 'Failed to load messages'; + + @override + String get errorFailedToLoadNotifications => 'Failed to load notifications'; + + @override + String get errorUnableToRemoveAccountNow => 'Unable to remove account right now.'; + + @override + String formatActorListTwo(String first, String second) { + return '$first and $second'; + } + + @override + String formatActorListWithOthers(String first, String second, int count) { + return '$first, $second, and $count others'; + } + + @override + String formatMonthDay(String month, int day) { + return '$month $day'; + } + + @override + String formatRemoveAccountContent(String handle) { + return 'Remove @$handle from this device?'; + } + + @override + String get labelAccounts => 'Accounts'; + + @override + String get labelAlertsTitle => 'Alerts'; + + @override + String get labelConversation => 'Conversation'; + + @override + String get labelFollows => 'Follows'; + + @override + String get labelLikes => 'Likes'; + + @override + String get labelMessageRequests => 'Requests'; + + @override + String get labelOther => 'Other'; + + @override + String get labelPrimary => 'Primary'; + + @override + String get labelSomeone => 'Someone'; + + @override + String get messageCopied => 'Message copied'; + + @override + String get messageDeleted => 'Message deleted'; + + @override + String get messageLocalNotificationFallbackBody => 'sent a notification'; + + @override + String get messageNewNotification => 'New notification'; + + @override + String get messageNoConnection => 'No connection'; + + @override + String get messageNoConversationsYet => 'No conversations yet'; + + @override + String get messageNoMessageRequests => 'No message requests'; + + @override + String get messageNoMessagesYet => 'No messages yet'; + + @override + String get messageNoNotificationsYet => 'No notifications yet'; + + @override + String get messageNotificationContactMatch => 'joined from your contacts'; + + @override + String get messageNotificationFollow => 'followed you'; + + @override + String get messageNotificationInteracted => 'interacted with you'; + + @override + String get messageNotificationLike => 'liked your post'; + + @override + String get messageNotificationLikeViaRepost => 'liked your repost'; + + @override + String get messageNotificationMention => 'mentioned you'; + + @override + String get messageNotificationQuote => 'quoted your post'; + + @override + String get messageNotificationReply => 'replied to your post'; + + @override + String get messageNotificationRepost => 'reposted your post'; + + @override + String get messageNotificationRepostViaRepost => 'reposted your repost'; + + @override + String get messageNotificationStarterPackJoined => 'joined via your starter pack'; + + @override + String get messageNotificationSubscribedPost => 'posted a new update'; + + @override + String get messageNotificationUnverified => 'removed your verification'; + + @override + String get messageNotificationVerified => 'verified your account'; + + @override + String get messagePlaceholder => 'Message…'; + + @override + String get messagePleaseSignInAgainForAccount => 'Please sign in again for that account.'; + + @override + String get messageReconnectToLoadMessages => 'Reconnect to load messages.'; + + @override + String get messageReconnectToLoadNotifications => 'Reconnect to load notifications.'; + + @override + String get messageThreadCopied => 'Thread copied'; + + @override + String get messageToday => 'Today'; + + @override + String get messageYesterday => 'Yesterday'; + + @override + String get placeholderUsernameBskySocial => 'username.bsky.social'; + + @override + String get validationEnterBlueskyHandleOrDid => 'Enter a Bluesky handle or DID'; + + @override + String get validationEnterCompleteDid => 'Enter a complete DID like did:plc:... or did:web:...'; + + @override + String get validationEnterFullHandle => 'Enter a full handle like username.bsky.social'; + + @override + String get validationUseSupportedDid => 'Use a did:plc:... or did:web:... identifier'; + + @override + String get validationEnterAppPassword => 'Enter your app password'; + + @override + String get buttonAdd => 'Add'; + + @override + String get buttonAdding => 'Adding...'; + + @override + String get buttonInspiredByPdsLs => 'Inspired by pds.ls'; + + @override + String get buttonCopyJson => 'Copy JSON'; + + @override + String get buttonResolve => 'Resolve'; + + @override + String get buttonUnsubscribe => 'Unsubscribe'; + + @override + String get dialogAddLabelerTitle => 'Add labeler'; + + @override + String get dialogClearAllLogsContent => 'This will permanently delete all log files. This action cannot be undone.'; + + @override + String get dialogClearAllLogsTitle => 'Clear all logs?'; + + @override + String get errorFailedToLoadLogs => 'Failed to load logs'; + + @override + String get errorFailedToLoadModerationSettings => 'Failed to load moderation settings'; + + @override + String errorFailedToUnsubscribeLabeler(Object error) { + return 'Failed to unsubscribe: $error'; + } + + @override + String errorFailedToUpdateAdultContent(Object error) { + return 'Failed to update adult content: $error'; + } + + @override + String errorFailedToUpdateLabelPreference(Object error) { + return 'Failed to update preference: $error'; + } + + @override + String errorFailedToUpdateLabelerSubscription(Object error) { + return 'Failed to update subscription: $error'; + } + + @override + String get errorLabelerDidRequired => 'Enter a labeler DID.'; + + @override + String get errorLabelerNotFound => 'Labeler not found.'; + + @override + String get errorNoLabelerFoundForDid => 'No labeler found for that DID.'; + + @override + String get errorUnableToLoadLabeler => 'Unable to load labeler'; + + @override + String formatAddLabelerLimit(int current, int max) { + return 'Add ($current/$max)'; + } + + @override + String formatCollectionsCount(int count) { + String _temp0 = intl.Intl.pluralLogic(count, locale: localeName, other: '$count collections', one: '1 collection'); + return '$_temp0'; + } + + @override + String formatCid(String cid) { + return 'CID: $cid'; + } + + @override + String formatCustomLabelCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count custom labels', + one: '1 custom label', + ); + return '$_temp0'; + } + + @override + String formatDefinitionCount(int count) { + String _temp0 = intl.Intl.pluralLogic(count, locale: localeName, other: '$count definitions', one: '1 definition'); + return '$_temp0'; + } + + @override + String formatLoadedRecordsCount(int count) { + return '$count loaded'; + } + + @override + String formatLoadedRecordsOfTotal(int loaded, int total) { + return '$loaded of $total'; + } + + @override + String formatModerationSourceLabel(String source) { + return '$source label'; + } + + @override + String formatPolicyBlur(String value) { + return 'Blur $value'; + } + + @override + String formatPolicyDefault(String value) { + return 'Default $value'; + } + + @override + String formatPolicyId(String identifier) { + return 'ID $identifier'; + } + + @override + String formatPolicySeverity(String value) { + return 'Severity $value'; + } + + @override + String formatPublishedValueCount(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count published values', + one: '1 published value', + ); + return '$_temp0'; + } + + @override + String formatRecordsCount(int count) { + String _temp0 = intl.Intl.pluralLogic(count, locale: localeName, other: '$count records', one: '1 record'); + return '$_temp0'; + } + + @override + String formatSubscribedToLabeler(String name) { + return 'Subscribed to $name'; + } + + @override + String get labelAdultContentSetting => 'Adult content'; + + @override + String get labelAdultOnlyShort => '18+'; + + @override + String get labelAlwaysOn => 'Always on'; + + @override + String get labelAutoScroll => 'Auto-scroll'; + + @override + String get labelBlueskyModeration => 'Bluesky moderation'; + + @override + String get labelBuiltIn => 'Built-in'; + + @override + String get labelBuiltInLabeler => 'Built-in labeler'; + + @override + String get labelBuiltInModeration => 'Built-in moderation'; + + @override + String get labelBlockedAccount => 'Blocked account'; + + @override + String get labelBlockedByAccount => 'Blocked by account'; + + @override + String get labelBlockedRelationship => 'Blocked relationship'; + + @override + String get labelCollections => 'COLLECTIONS'; + + @override + String get labelContentPreferenceHide => 'Hide'; + + @override + String get labelContentPreferenceIgnore => 'Ignore'; + + @override + String get labelContentPreferenceWarn => 'Warn'; + + @override + String get labelCustomLabelers => 'Custom labelers'; + + @override + String get labelLabeler => 'Labeler'; + + @override + String get labelLabelerDid => 'Labeler DID'; + + @override + String get labelLabelersAndContentModeration => 'Labelers and content moderation'; + + @override + String get labelLabelPreferences => 'Label preferences'; + + @override + String get labelHiddenContent => 'Hidden content'; + + @override + String get labelLogLevelDebug => 'Debug'; + + @override + String get labelLogLevelError => 'Error'; + + @override + String get labelLogLevelFatal => 'Fatal'; + + @override + String get labelLogLevelInfo => 'Info'; + + @override + String get labelLogLevelTrace => 'Trace'; + + @override + String get labelLogLevelWarning => 'Warning'; + + @override + String get labelModerationNote => 'Moderation note'; + + @override + String get labelModerationSourceBluesky => 'Bluesky'; + + @override + String get labelModerationSourceSubscribedLabeler => 'Subscribed labeler'; + + @override + String get labelMutedAccount => 'Muted account'; + + @override + String get labelMutedPhrase => 'Muted phrase'; + + @override + String get labelNoCustomLabelDefinitions => 'No custom label definitions'; + + @override + String get labelNoCustomLabelers => 'No custom labelers'; + + @override + String get labelPdsExplorer => 'PDS Explorer'; + + @override + String get labelPublishedPolicies => 'Published policies'; + + @override + String get labelRecordJson => 'Record JSON'; + + @override + String get labelRefresh => 'Refresh'; + + @override + String get labelRepository => 'Repository'; + + @override + String get labelSubscribed => 'Subscribed'; + + @override + String get labelSensitiveContent => 'Sensitive content'; + + @override + String get labelUnknown => 'Unknown'; + + @override + String get messageAddLabelerDidHelper => 'Paste a labeler DID to review and subscribe to its labels.'; + + @override + String get messageAdultContentRequiredForLabels => 'Required before 18+ label preferences can be changed.'; + + @override + String get messageBuiltInLabelerActiveWhenUnavailable => + 'The built-in labeler is active even if its details cannot be loaded right now.'; + + @override + String get messageBuiltInLabelerAlwaysActive => 'This labeler is always active.'; + + @override + String get messageDevtoolsEmptyState => 'Enter a handle, DID, or AT-URI to explore\na user\'s repository.'; + + @override + String get messageEnableAdultContentForLabel => 'Enable adult content to change this 18+ label.'; + + @override + String get messageBlockedAccountDescription => 'This account is blocked'; + + @override + String get messageBlockedByAccountDescription => 'This account has blocked you'; + + @override + String get messageBlockedRelationshipDescription => 'This content is limited by a block relationship'; + + @override + String get messageHiddenContentDescription => 'This content is hidden by moderation rules'; + + @override + String get messageJsonCopiedToClipboard => 'JSON copied to clipboard'; + + @override + String get messageLogsEmpty => 'No logs yet'; + + @override + String get messageLogsEmptySubtitle => 'Log entries will appear here'; + + @override + String get messageModerationSettingsHeroSubtitle => + 'Manage adult-content visibility, subscribed labelers, and the rules each labeler applies to posts and profiles.'; + + @override + String get messageModerationGuidanceApplies => 'Moderation guidance applies here'; + + @override + String get messageMutedAccountDescription => 'Muted content is being downranked here'; + + @override + String get messageMutedPhraseDescription => 'A muted phrase matched this content'; + + @override + String get messageNoCustomLabelDefinitions => 'This labeler publishes values, but not localized custom definitions.'; + + @override + String get messageNoCustomLabelers => 'Add a labeler DID to subscribe and configure its custom labels.'; + + @override + String get messageNoLabelDescriptionAvailable => 'No description available for this label.'; + + @override + String get messageNoLogFileAvailable => 'No log file available'; + + @override + String get messageRecordCountsUnavailable => 'Record counts unavailable'; + + @override + String get messageRecordCountsLoading => 'Counting records...'; + + @override + String get messageSubscribedLabelersHeaders => + 'Subscribed labelers are added to your moderation headers and preferences.'; + + @override + String get messageUnableToOpenShareSheet => 'Unable to open share sheet. Please try again.'; + + @override + String get placeholderHandleDidOrAtUri => 'Handle, DID, or at:// URI'; + + @override + String get placeholderLabelerDid => 'did:plc:examplelabeler'; + + @override + String get placeholderLogsFilter => 'Filter logs...'; + + @override + String get subjectLazuriteLogs => 'Lazurite logs'; + + @override + String get tooltipClearAllLogs => 'Clear all logs'; + + @override + String get tooltipGoToPdsLs => 'Go to pds.ls'; + + @override + String get tooltipShareLogFile => 'Share log file'; } diff --git a/lib/core/l10n/intl_en.arb b/lib/core/l10n/intl_en.arb index d1bf216..cac9bc0 100644 --- a/lib/core/l10n/intl_en.arb +++ b/lib/core/l10n/intl_en.arb @@ -13,6 +13,10 @@ "@buttonCancel": { "description": "Cancel button label" }, + "buttonCompose": "Compose", + "@buttonCompose": { + "description": "Tooltip for composing a post" + }, "buttonClearCache": "Clear Cache", "@buttonClearCache": { "description": "Button label to clear local cache" @@ -21,6 +25,26 @@ "@buttonContinue": { "description": "Continue button label" }, + "buttonClearLocal": "Clear Local", + "@buttonClearLocal": { + "description": "Button label to clear locally stored items" + }, + "buttonDelete": "Delete", + "@buttonDelete": { + "description": "Delete button label" + }, + "buttonDiscard": "Discard", + "@buttonDiscard": { + "description": "Discard button label" + }, + "buttonLoadMoreQuotes": "Load more quotes", + "@buttonLoadMoreQuotes": { + "description": "Button label to load additional quote posts" + }, + "buttonLoadMoreReposts": "Load more reposts", + "@buttonLoadMoreReposts": { + "description": "Button label to load additional repost users" + }, "buttonRemove": "Remove", "@buttonRemove": { "description": "Remove button label" @@ -41,6 +65,14 @@ "@buttonOpen": { "description": "Open button label" }, + "buttonOk": "OK", + "@buttonOk": { + "description": "OK button label" + }, + "buttonPost": "Post", + "@buttonPost": { + "description": "Button label to publish a post" + }, "buttonResetSignInData": "Reset Sign-In Data", "@buttonResetSignInData": { "description": "Button label to clear local sign-in data" @@ -49,6 +81,14 @@ "@buttonRetry": { "description": "Retry button label" }, + "buttonSave": "Save", + "@buttonSave": { + "description": "Save button label" + }, + "buttonSaveChanges": "Save Changes", + "@buttonSaveChanges": { + "description": "Button label to save post edits" + }, "buttonSignIn": "Sign In", "@buttonSignIn": { "description": "Sign in button label" @@ -57,6 +97,10 @@ "@buttonShowContent": { "description": "Button label to reveal moderated content" }, + "buttonShare": "Share", + "@buttonShare": { + "description": "Share button or menu label" + }, "buttonTryAgain": "Try again", "@buttonTryAgain": { "description": "Try again button label" @@ -69,6 +113,14 @@ "@commonNone": { "description": "Label for no value" }, + "commonNow": "now", + "@commonNow": { + "description": "Lowercase relative time label for the current moment" + }, + "commonJustNow": "Just now", + "@commonJustNow": { + "description": "Relative time label for the current moment" + }, "commonNotCheckedYet": "Not checked yet", "@commonNotCheckedYet": { "description": "Label for a health check that has not run" @@ -77,6 +129,10 @@ "@commonOff": { "description": "Disabled option label" }, + "commonUnknown": "unknown", + "@commonUnknown": { + "description": "Lowercase fallback for an unknown value" + }, "dialogClearCacheContent": "This removes cached posts, profiles, images, feeds, threads, label data, and local semantic search data.\n\nAccounts, settings, drafts, bookmarks, and likes are kept.", "@dialogClearCacheContent": { "description": "Confirmation dialog body before clearing local cache" @@ -85,6 +141,50 @@ "@dialogClearCacheTitle": { "description": "Confirmation dialog title before clearing local cache" }, + "dialogClearLocalBookmarksContent": "This removes only local bookmarks from this device. Bluesky cloud bookmarks will not be deleted.", + "@dialogClearLocalBookmarksContent": { + "description": "Confirmation dialog body before clearing local bookmarks" + }, + "dialogClearLocalBookmarksTitle": "Clear local bookmarks?", + "@dialogClearLocalBookmarksTitle": { + "description": "Confirmation dialog title before clearing local bookmarks" + }, + "dialogDeletePostContent": "This action cannot be undone.", + "@dialogDeletePostContent": { + "description": "Confirmation dialog body before deleting a post" + }, + "dialogDeletePostTitle": "Delete Post?", + "@dialogDeletePostTitle": { + "description": "Confirmation dialog title before deleting a post" + }, + "dialogDeleteDraftTitle": "Delete Draft?", + "@dialogDeleteDraftTitle": { + "description": "Confirmation dialog title before deleting a draft" + }, + "dialogDiscardChangesContent": "You have unsaved edits. Discard them and leave?", + "@dialogDiscardChangesContent": { + "description": "Confirmation dialog body before leaving unsaved post edits" + }, + "dialogDiscardChangesTitle": "Discard Changes?", + "@dialogDiscardChangesTitle": { + "description": "Confirmation dialog title before leaving unsaved post edits" + }, + "dialogEditAlgorithmContent": "Lazurite saves edits by deleting and recreating the post record with the same URI. During re-indexing, ranking, counters, and search visibility can shift, and updates may take time to appear everywhere.", + "@dialogEditAlgorithmContent": { + "description": "Information dialog body explaining how post editing is implemented" + }, + "dialogEditAlgorithmTitle": "How Post Editing Works", + "@dialogEditAlgorithmTitle": { + "description": "Information dialog title explaining how post editing works" + }, + "dialogSaveDraftContent": "You have unsaved content. Would you like to save it as a draft?", + "@dialogSaveDraftContent": { + "description": "Confirmation dialog body before leaving compose with unsaved content" + }, + "dialogSaveDraftTitle": "Save Draft?", + "@dialogSaveDraftTitle": { + "description": "Confirmation dialog title before leaving compose with unsaved content" + }, "dialogRemoveAccountContent": "Remove @{handle} from this device?", "@dialogRemoveAccountContent": { "description": "Confirmation dialog body before removing a saved account from this device", @@ -139,6 +239,52 @@ "@errorGenericTitle": { "description": "Generic error state title" }, + "errorFailedToLoadBookmarks": "Failed to load bookmarks", + "@errorFailedToLoadBookmarks": { + "description": "Error title shown when bookmarks cannot load" + }, + "errorFailedToLoadLikedPosts": "Failed to load liked posts", + "@errorFailedToLoadLikedPosts": { + "description": "Error title shown when liked posts cannot load" + }, + "errorFailedToLoadLikedPostsDetails": "Failed to load liked posts: {error}", + "@errorFailedToLoadLikedPostsDetails": { + "description": "Error message shown when liked posts cannot load", + "placeholders": { + "error": { + "type": "Object", + "example": "network unavailable" + } + } + }, + "errorFailedToRefreshLikedPosts": "Failed to refresh liked posts: {error}", + "@errorFailedToRefreshLikedPosts": { + "description": "Warning message shown when refreshing liked posts fails", + "placeholders": { + "error": { + "type": "Object", + "example": "network unavailable" + } + } + }, + "errorFailedToLoadTrending": "Failed to load trending", + "@errorFailedToLoadTrending": { + "description": "Error title shown when trending content cannot load" + }, + "errorFailedToLoadTrendingTopics": "Failed to load trending topics: {error}", + "@errorFailedToLoadTrendingTopics": { + "description": "Error message shown when trending topics cannot load", + "placeholders": { + "error": { + "type": "Object", + "example": "network unavailable" + } + } + }, + "errorUnknown": "Unknown error", + "@errorUnknown": { + "description": "Fallback message when an error has no details" + }, "errorUnableToRemoveAccount": "Unable to remove account right now.", "@errorUnableToRemoveAccount": { "description": "Snackbar message when a saved account cannot be removed" @@ -187,6 +333,216 @@ } } }, + "formatLikedOn": "Liked on {date}", + "@formatLikedOn": { + "description": "Fallback liked-post card subtitle showing when a post was liked", + "placeholders": { + "date": { + "type": "String", + "example": "Mar 15" + } + } + }, + "formatLikesCount": "{count, plural, =1{1 Like} other{{count} Likes}}", + "@formatLikesCount": { + "description": "Interaction sheet tab label showing like count", + "placeholders": { + "count": { + "type": "int", + "example": "12" + } + } + }, + "formatOfflineReconnectAction": "You are offline. Reconnect to {action}.", + "@formatOfflineReconnectAction": { + "description": "Message explaining that a post action requires reconnecting", + "placeholders": { + "action": { + "type": "String", + "example": "like this post" + } + } + }, + "formatReplyingToHandle": "Replying to @{handle}", + "@formatReplyingToHandle": { + "description": "Post card reply context label showing the parent post author", + "placeholders": { + "handle": { + "type": "String", + "example": "alice.bsky.social" + } + } + }, + "formatRepostsCount": "{count, plural, =1{1 Repost} other{{count} Reposts}}", + "@formatRepostsCount": { + "description": "Interaction sheet tab label showing repost count", + "placeholders": { + "count": { + "type": "int", + "example": "8" + } + } + }, + "formatSavedOn": "Saved on {date}", + "@formatSavedOn": { + "description": "Fallback saved-post card subtitle showing when a post was saved", + "placeholders": { + "date": { + "type": "String", + "example": "Mar 15" + } + } + }, + "formatTrendingCategory": "Category: {category}", + "@formatTrendingCategory": { + "description": "Trending topic category subtitle line", + "placeholders": { + "category": { + "type": "String", + "example": "Technology" + } + } + }, + "formatTrendingPostCount": "{count, plural, =1{1 post} other{{count} posts}}", + "@formatTrendingPostCount": { + "description": "Trending topic post count subtitle line", + "placeholders": { + "count": { + "type": "int", + "example": "1200" + } + } + }, + "formatViewHandle": "View @{handle}", + "@formatViewHandle": { + "description": "Post overflow action label to view an author profile", + "placeholders": { + "handle": { + "type": "String", + "example": "alice.bsky.social" + } + } + }, + "formatComposeFailedToPickImage": "Failed to pick image: {error}", + "@formatComposeFailedToPickImage": { + "description": "Snackbar message when the image picker fails", + "placeholders": { + "error": { + "type": "Object", + "example": "permission denied" + } + } + }, + "formatComposeFailedToPickVideo": "Failed to pick video: {error}", + "@formatComposeFailedToPickVideo": { + "description": "Snackbar message when the video picker fails", + "placeholders": { + "error": { + "type": "Object", + "example": "permission denied" + } + } + }, + "formatComposeFailedToSaveChanges": "Failed to save changes: {error}", + "@formatComposeFailedToSaveChanges": { + "description": "Compose error message when saving edits fails with details", + "placeholders": { + "error": { + "type": "Object", + "example": "network unavailable" + } + } + }, + "formatComposeFailedToSubmitPost": "Failed to submit post: {error}", + "@formatComposeFailedToSubmitPost": { + "description": "Compose error message when post submission fails with details", + "placeholders": { + "error": { + "type": "Object", + "example": "network unavailable" + } + } + }, + "formatComposeImageTooLarge": "Image \"{fileName}\" is {sizeMb} MB - max 1 MB.", + "@formatComposeImageTooLarge": { + "description": "Compose validation error when an attached image is too large", + "placeholders": { + "fileName": { + "type": "String", + "example": "photo.png" + }, + "sizeMb": { + "type": "String", + "example": "1.4" + } + } + }, + "formatComposeQuotingHandle": "Quoting @{handle}", + "@formatComposeQuotingHandle": { + "description": "Compose quote preview label with the quoted author's handle", + "placeholders": { + "handle": { + "type": "String", + "example": "alice.bsky.social" + } + } + }, + "formatComposeScheduledFor": "Scheduled for {dateTime}", + "@formatComposeScheduledFor": { + "description": "Compose scheduled post pill label", + "placeholders": { + "dateTime": { + "type": "String", + "example": "May 8, 2:30 PM" + } + } + }, + "formatComposeVideoReadyWithAltText": "Ready - \"{altText}\"", + "@formatComposeVideoReadyWithAltText": { + "description": "Video attachment status when ready and alt text exists", + "placeholders": { + "altText": { + "type": "String", + "example": "A dog running through a park" + } + } + }, + "formatComposeVideoTooLarge": "Video is {sizeMb} MB - exceeds the 100 MB limit.", + "@formatComposeVideoTooLarge": { + "description": "Compose validation error when a video is too large", + "placeholders": { + "sizeMb": { + "type": "String", + "example": "125.4" + } + } + }, + "formatDraftCount": "{count, plural, =1{1 draft} other{{count} drafts}}", + "@formatDraftCount": { + "description": "Compose drafts panel count label", + "placeholders": { + "count": { + "type": "int", + "example": "3" + } + } + }, + "actionLikeThisPost": "like this post", + "@actionLikeThisPost": { + "description": "Offline action phrase for liking a post" + }, + "actionReplyToThisPost": "reply to this post", + "@actionReplyToThisPost": { + "description": "Offline action phrase for replying to a post" + }, + "actionRepostThisPost": "repost this post", + "@actionRepostThisPost": { + "description": "Offline action phrase for reposting a post" + }, + "actionPublishYourPost": "publish your post", + "@actionPublishYourPost": { + "description": "Offline action phrase for publishing a post" + }, "labelAbout": "About", "@labelAbout": { "description": "About page or settings label" @@ -255,6 +611,26 @@ "@labelBookmarksAndLikes": { "description": "Settings item for bookmarked and liked posts" }, + "labelBookmarkActions": "Bookmark actions", + "@labelBookmarkActions": { + "description": "Tooltip for bookmark actions menu" + }, + "labelBookmarkedPost": "Bookmarked Post", + "@labelBookmarkedPost": { + "description": "Fallback saved-post card title" + }, + "labelBookmarks": "Bookmarks", + "@labelBookmarks": { + "description": "Bookmarks tab label" + }, + "labelBluesky": "Bluesky", + "@labelBluesky": { + "description": "Bluesky source tab label" + }, + "labelAlt": "ALT", + "@labelAlt": { + "description": "Short all-caps label for media alt text controls" + }, "labelCacheCleared": "Cache cleared", "@labelCacheCleared": { "description": "Snackbar message after clearing cache" @@ -267,6 +643,10 @@ "@labelCleanFollows": { "description": "Settings item for follow audit feature" }, + "labelClose": "Close", + "@labelClose": { + "description": "Close button tooltip" + }, "labelClearCache": "Clear Cache", "@labelClearCache": { "description": "Settings item for clearing cache" @@ -395,6 +775,10 @@ "@labelNewPost": { "description": "New post menu label" }, + "labelNow": "NOW", + "@labelNow": { + "description": "Uppercase compact relative time label for the current moment" + }, "labelNotifications": "Notifications", "@labelNotifications": { "description": "Notifications menu label" @@ -515,6 +899,106 @@ "@labelClearUntil": { "description": "Button label to clear until date filter" }, + "labelClearLocalBookmarks": "Clear local bookmarks", + "@labelClearLocalBookmarks": { + "description": "Bookmark menu item to clear local bookmarks" + }, + "labelCopyLink": "Copy Link", + "@labelCopyLink": { + "description": "Post overflow menu item to copy a post link" + }, + "labelDeletePost": "Delete Post", + "@labelDeletePost": { + "description": "Post overflow menu item to delete a post" + }, + "labelDeleteDraft": "Delete draft", + "@labelDeleteDraft": { + "description": "Tooltip for deleting a draft" + }, + "labelEditPost": "Edit Post", + "@labelEditPost": { + "description": "Post overflow menu item to edit a post" + }, + "labelLiked": "Liked", + "@labelLiked": { + "description": "Liked posts tab label" + }, + "labelLikedBy": "LIKED BY", + "@labelLikedBy": { + "description": "Post interactions sheet section label for users who liked a post" + }, + "labelLikedPost": "Liked Post", + "@labelLikedPost": { + "description": "Fallback liked-post card title" + }, + "labelMoreInfo": "More info", + "@labelMoreInfo": { + "description": "Tooltip for opening more information" + }, + "labelLocal": "Local", + "@labelLocal": { + "description": "Local saved-post source tab label" + }, + "labelOpenPost": "Open post", + "@labelOpenPost": { + "description": "Tooltip to open a post" + }, + "labelQuotePost": "Quote Post", + "@labelQuotePost": { + "description": "Post action label to quote a post" + }, + "labelQuoteReposts": "QUOTE / REPOSTS", + "@labelQuoteReposts": { + "description": "Heading for quote and repost bottom sheet" + }, + "labelQuotes": "Quotes", + "@labelQuotes": { + "description": "Quotes section title" + }, + "labelRemoveFromBluesky": "Remove from Bluesky", + "@labelRemoveFromBluesky": { + "description": "Post save menu item to remove a cloud bookmark" + }, + "labelRemoveLocalSave": "Remove local save", + "@labelRemoveLocalSave": { + "description": "Post save menu item to remove a local bookmark" + }, + "labelReportPost": "Report Post", + "@labelReportPost": { + "description": "Post overflow menu item to report a post" + }, + "labelRepost": "Repost", + "@labelRepost": { + "description": "Post action label to repost a post" + }, + "labelRepostedBy": "REPOSTED BY", + "@labelRepostedBy": { + "description": "Post interactions sheet section label for users who reposted a post" + }, + "labelReposts": "Reposts", + "@labelReposts": { + "description": "Reposts section title" + }, + "labelSaveImage": "Save image", + "@labelSaveImage": { + "description": "Image context menu item to save an image" + }, + "labelSaveLocally": "Save locally", + "@labelSaveLocally": { + "description": "Post save menu item to create a local bookmark" + }, + "labelSaveToBluesky": "Save to Bluesky", + "@labelSaveToBluesky": { + "description": "Post save menu item to create a cloud bookmark" + }, + "labelSchedule": "Schedule", + "@labelSchedule": { + "description": "Compose toolbar schedule button tooltip" + }, + "labelScheduled": "Scheduled", + "@labelScheduled": { + "description": "Scheduled draft badge label" + }, "labelSavedAccounts": "Saved accounts", "@labelSavedAccounts": { "description": "Saved accounts section label on login screen" @@ -527,6 +1011,14 @@ "@labelSemanticSearch": { "description": "Semantic search settings label" }, + "labelShowLikedUsers": "Show Liked Users", + "@labelShowLikedUsers": { + "description": "Post overflow menu item to open users who liked a post" + }, + "labelShowQuoteRepostList": "Show Quote/Repost List", + "@labelShowQuoteRepostList": { + "description": "Post overflow menu item to open quote and repost lists" + }, "labelSettings": "Settings", "@labelSettings": { "description": "Settings page title or tooltip" @@ -567,6 +1059,30 @@ "@labelTroubleshooting": { "description": "Troubleshooting settings section label" }, + "labelVideo": "Video", + "@labelVideo": { + "description": "Fallback video label" + }, + "labelTopics": "Topics", + "@labelTopics": { + "description": "Trending topics section label" + }, + "labelTrending": "Trending", + "@labelTrending": { + "description": "Trending screen title and tooltip label" + }, + "labelSuggested": "Suggested", + "@labelSuggested": { + "description": "Trending suggested topics section label" + }, + "labelSuggestedFollows": "Suggested Follows", + "@labelSuggestedFollows": { + "description": "Suggested follows sheet and menu label" + }, + "labelUnrepost": "Unrepost", + "@labelUnrepost": { + "description": "Post action label to undo a repost" + }, "labelTypeaheadProvider": "Typeahead Provider", "@labelTypeaheadProvider": { "description": "Typeahead provider settings label" @@ -655,6 +1171,14 @@ "@messageFeedsSubtitle": { "description": "Settings subtitle for feeds" }, + "messageLinkCopiedToClipboard": "Link copied to clipboard", + "@messageLinkCopiedToClipboard": { + "description": "Snackbar message after copying a post link" + }, + "messageLoadingTrendingTopics": "Loading trending topics", + "@messageLoadingTrendingTopics": { + "description": "Loading message while trending topics load" + }, "messageForceNextXrpc401Subtitle": "Debug-only: next network request returns Unauthorized to test token refresh", "@messageForceNextXrpc401Subtitle": { "description": "Developer setting subtitle for forced 401" @@ -663,12 +1187,14 @@ "@messageManageSemanticSearchSubtitle": { "description": "Settings subtitle for semantic search management" }, - + "messageMetadataTemporarilyUnavailable": "Metadata temporarily unavailable", + "@messageMetadataTemporarilyUnavailable": { + "description": "Trending banner shown when supplemental metadata cannot be loaded" + }, "messageModeratedContentCannotReveal": "Hidden by your moderation settings and cannot be revealed here.", "@messageModeratedContentCannotReveal": { "description": "Moderation overlay description when content cannot be revealed" }, - "messageModeratedContentCanReveal": "Hidden by your moderation settings. You can reveal it for this view.", "@messageModeratedContentCanReveal": { "description": "Moderation overlay description when content can be revealed" @@ -677,71 +1203,343 @@ "@messageProviderDiagnosticsSubtitle": { "description": "Settings subtitle for provider diagnostics" }, - "messageRefreshProviderHealthSubtitle": "Probe public AppView endpoints now", - "@messageRefreshProviderHealthSubtitle": { - "description": "Settings subtitle for refreshing provider health" + "messageLikedPostsUnavailable": "Liked posts are unavailable right now.", + "@messageLikedPostsUnavailable": { + "description": "Message shown when liked posts dependencies are unavailable" }, - "messageResetSignInDataSubtitle": "Troubleshoot OAuth or account-switching issues by clearing local sessions on this device", - "@messageResetSignInDataSubtitle": { - "description": "Settings subtitle for resetting sign-in data" + "messageChangesSaved": "Changes saved.", + "@messageChangesSaved": { + "description": "Snackbar message after post edits are saved" }, - "messageSearchSubtitle": "Search", - "@messageSearchSubtitle": { - "description": "Generic search subtitle" + "messageComposeAddAltText": "Add alt text", + "@messageComposeAddAltText": { + "description": "Tooltip for adding video alt text" }, - "messageClearSearchHistoryContent": "This will delete all your recent searches.", - "@messageClearSearchHistoryContent": { - "description": "Confirmation dialog body before clearing search history" + "messageComposeAddImage": "Add image", + "@messageComposeAddImage": { + "description": "Compose toolbar add image tooltip" }, - "messageClearSearchHistoryTitle": "Clear search history?", - "@messageClearSearchHistoryTitle": { - "description": "Confirmation dialog title before clearing search history" + "messageComposeAddVideo": "Add video", + "@messageComposeAddVideo": { + "description": "Compose toolbar add video tooltip" }, - "messageStartTypingToSearchHandles": "Start typing to search handles.", - "@messageStartTypingToSearchHandles": { - "description": "Hint shown in jump to profile dialog" + "messageComposeClearScheduledTime": "Clear scheduled time", + "@messageComposeClearScheduledTime": { + "description": "Tooltip for clearing scheduled compose time" }, - "messageStarterPackSearchApiUnavailable": "Starter pack search is not available in the API yet.", - "@messageStarterPackSearchApiUnavailable": { - "description": "Helper text when starter pack search is disabled" + "messageComposeDescribeImage": "Describe the image", + "@messageComposeDescribeImage": { + "description": "Image alt text field placeholder" }, - "messageStarterPackSearchUnavailableTitle": "Starter Pack Search Is Unavailable", - "@messageStarterPackSearchUnavailableTitle": { - "description": "Starter pack search unavailable state title" + "messageComposeDescribeVideo": "Describe the video", + "@messageComposeDescribeVideo": { + "description": "Video alt text field placeholder" }, - "messageStarterPackSearchUnavailableBody": "(Starter Pack Search is not yet implemented in the BlueSky API)", - "@messageStarterPackSearchUnavailableBody": { - "description": "Starter pack search unavailable state body" + "messageComposeDraftSaved": "Draft saved", + "@messageComposeDraftSaved": { + "description": "Snackbar message after saving a draft" }, - "messageTrackApiProgress": "Track API progress", - "@messageTrackApiProgress": { - "description": "Button label to open upstream API issue" + "messageComposeDrafts": "Drafts", + "@messageComposeDrafts": { + "description": "Compose drafts panel title and toolbar tooltip" }, - "messageCouldNotOpenIssueLink": "Could not open issue link.", - "@messageCouldNotOpenIssueLink": { - "description": "Snackbar message when opening upstream issue link fails" + "messageComposeEditNotice": "Edits are saved by replacing the record while keeping this post URI. Ranking, counts, and visibility may shift while networks re-index.", + "@messageComposeEditNotice": { + "description": "Compose edit mode notice banner" }, - "messageSearchPostsPlaceholder": "Search posts", - "@messageSearchPostsPlaceholder": { - "description": "Search posts field placeholder" + "messageComposeImageAltTextTitle": "Alt text", + "@messageComposeImageAltTextTitle": { + "description": "Image alt text dialog title" }, - "messageSearchThisProfilesPostsPlaceholder": "Search this profile''s posts", - "@messageSearchThisProfilesPostsPlaceholder": { - "description": "Search placeholder in profile posts-only mode" + "messageComposeImageMaxCount": "Maximum 4 images allowed", + "@messageComposeImageMaxCount": { + "description": "Compose validation message when too many images are attached" }, - "messageSearchPeoplePlaceholder": "Search people", - "@messageSearchPeoplePlaceholder": { - "description": "Search people field placeholder" + "messageComposeImageMustBeJpegPngWebp": "Image must be JPEG, PNG, or WebP", + "@messageComposeImageMustBeJpegPngWebp": { + "description": "Compose validation message for unsupported image extensions" }, - "messageSearchFeedsPlaceholder": "Search feeds", - "@messageSearchFeedsPlaceholder": { - "description": "Search feeds field placeholder" + "messageComposeImageMustBeUnder1Mb": "Image must be smaller than 1MB", + "@messageComposeImageMustBeUnder1Mb": { + "description": "Compose validation message for image picker size validation" }, - "messageStarterPackSearchUnavailablePlaceholder": "Starter pack search unavailable", - "@messageStarterPackSearchUnavailablePlaceholder": { - "description": "Starter pack search field placeholder" + "messageComposeNoDraftsSaved": "No drafts saved", + "@messageComposeNoDraftsSaved": { + "description": "Empty state text in compose drafts panel" }, - "messageSlingshotIdentityFallbackSubtitle": "If handle lookup fails, use Slingshot to find your DID and PDS so sign-in can continue", + "messageComposeNoText": "(No text)", + "@messageComposeNoText": { + "description": "Fallback draft content label when a draft has no text" + }, + "messageComposePlaceholder": "What''s on your mind?", + "@messageComposePlaceholder": { + "description": "Compose text field placeholder" + }, + "messageComposePreviewUnavailable": "Preview unavailable", + "@messageComposePreviewUnavailable": { + "description": "Video alt text preview unavailable message" + }, + "messageComposeQuotingPost": "Quoting post", + "@messageComposeQuotingPost": { + "description": "Compose quote preview label without an author handle" + }, + "messageComposeRemoveExistingMediaBeforeVideo": "Remove existing media before adding a video", + "@messageComposeRemoveExistingMediaBeforeVideo": { + "description": "Compose validation message when a video cannot be added because other media exists" + }, + "messageComposeRemoveImage": "Remove image", + "@messageComposeRemoveImage": { + "description": "Tooltip for removing an image attachment" + }, + "messageComposeRemoveQuotedPost": "Remove quoted post", + "@messageComposeRemoveQuotedPost": { + "description": "Tooltip for removing a quoted post from compose" + }, + "messageComposeSaveDraft": "Save draft", + "@messageComposeSaveDraft": { + "description": "Compose toolbar save draft tooltip" + }, + "messageComposeVideoAltTextTitle": "Video alt text", + "@messageComposeVideoAltTextTitle": { + "description": "Video alt text dialog title" + }, + "messageVideoCheckingUploadLimits": "Checking upload limits...", + "@messageVideoCheckingUploadLimits": { + "description": "Video attachment status while checking upload limits" + }, + "messageVideoDailyUploadLimitReached": "Daily video upload limit reached.", + "@messageVideoDailyUploadLimitReached": { + "description": "Video upload validation message when the daily limit is reached" + }, + "messageVideoProcessing": "Processing...", + "@messageVideoProcessing": { + "description": "Video attachment processing status" + }, + "messageVideoProcessingFailed": "Video processing failed.", + "@messageVideoProcessingFailed": { + "description": "Video attachment error when processing fails" + }, + "messageVideoProcessingTimedOut": "Video processing timed out.", + "@messageVideoProcessingTimedOut": { + "description": "Video attachment error when processing times out" + }, + "messageVideoReady": "Ready", + "@messageVideoReady": { + "description": "Video attachment ready status" + }, + "messageVideoReadyToUpload": "Ready to upload", + "@messageVideoReadyToUpload": { + "description": "Video attachment ready-to-upload status" + }, + "messageVideoUploadFailed": "Upload failed - please try again.", + "@messageVideoUploadFailed": { + "description": "Video upload generic failure message" + }, + "messageVideoUploading": "Uploading...", + "@messageVideoUploading": { + "description": "Video attachment uploading status" + }, + "errorComposeChangedElsewhere": "This post was changed elsewhere. Reopen it and try editing again.", + "@errorComposeChangedElsewhere": { + "description": "Compose edit error when the post changed remotely" + }, + "errorComposeCouldNotConfirmEdit": "Edit was submitted but could not be confirmed yet. Please reopen the post and verify.", + "@errorComposeCouldNotConfirmEdit": { + "description": "Compose edit error when an edit cannot be confirmed" + }, + "errorComposeCouldNotSaveAndConfirmRecovery": "Could not save changes and we could not confirm recovery. Reopen the thread and verify the post.", + "@errorComposeCouldNotSaveAndConfirmRecovery": { + "description": "Compose edit recovery error when save and recovery status are unknown" + }, + "errorComposeEditContextMissing": "Edit context is missing. Please reopen the editor and try again.", + "@errorComposeEditContextMissing": { + "description": "Compose edit validation error when edit context is missing" + }, + "errorComposeFailedToCreatePost": "Failed to create post. Please try again.", + "@errorComposeFailedToCreatePost": { + "description": "Compose error when creating a post fails" + }, + "errorComposeFailedToSaveChanges": "Failed to save changes. Please try again.", + "@errorComposeFailedToSaveChanges": { + "description": "Compose error when saving post edits fails" + }, + "errorComposeFailedToUploadImage": "Failed to upload image. Please try again.", + "@errorComposeFailedToUploadImage": { + "description": "Compose error when image upload fails" + }, + "errorComposeImageFileNotFound": "Image file not found. Please re-attach and try again.", + "@errorComposeImageFileNotFound": { + "description": "Compose error when an attached image file is missing" + }, + "errorComposeNetworkSavedAsDraft": "Network error - post saved as draft.", + "@errorComposeNetworkSavedAsDraft": { + "description": "Compose error when submission fails and is saved as a draft" + }, + "errorComposeOriginalPostRestored": "Could not save changes. Your original post was restored.", + "@errorComposeOriginalPostRestored": { + "description": "Compose edit error when the original post was restored after failure" + }, + "errorComposeUnsupportedImageFormat": "Unsupported image format. Use JPEG, PNG, or WebP.", + "@errorComposeUnsupportedImageFormat": { + "description": "Compose validation error for unsupported image bytes" + }, + "messageNoBookmarks": "No bookmarks", + "@messageNoBookmarks": { + "description": "Empty state title when there are no bookmarks" + }, + "messageNoBookmarksSubtitle": "Posts you bookmark will appear here", + "@messageNoBookmarksSubtitle": { + "description": "Empty state subtitle when there are no bookmarks" + }, + "messageNoBookmarksInSource": "No bookmarks in this source", + "@messageNoBookmarksInSource": { + "description": "Empty state title when a bookmark source tab is empty" + }, + "messageNoBookmarksInSourceSubtitle": "Try switching tabs or saving posts to this source", + "@messageNoBookmarksInSourceSubtitle": { + "description": "Empty state subtitle when a bookmark source tab is empty" + }, + "messageNoInteractionsYet": "No interactions yet", + "@messageNoInteractionsYet": { + "description": "Empty state message when a post has no likes or reposts" + }, + "messageNoLikedPosts": "No liked posts", + "@messageNoLikedPosts": { + "description": "Empty state title when there are no liked posts" + }, + "messageNoLikedPostsSubtitle": "Posts you like will appear here after sync", + "@messageNoLikedPostsSubtitle": { + "description": "Empty state subtitle when there are no liked posts" + }, + "messageNoLikedPostsYet": "No liked posts yet", + "@messageNoLikedPostsYet": { + "description": "Empty state when a profile has no liked posts" + }, + "messageNoQuotesYet": "No quotes yet", + "@messageNoQuotesYet": { + "description": "Empty state message when a post has no quote posts" + }, + "messageNoRepostsYet": "No reposts yet", + "@messageNoRepostsYet": { + "description": "Empty state message when a post has no reposts" + }, + "messageNoTrendingTopicsRightNow": "No trending topics right now", + "@messageNoTrendingTopicsRightNow": { + "description": "Empty state message when there are no trending topics" + }, + "messagePostDeleted": "Post deleted", + "@messagePostDeleted": { + "description": "Snackbar message after deleting a post" + }, + "messageQuotePostSubtitle": "Quote this post with your own text", + "@messageQuotePostSubtitle": { + "description": "Post action subtitle for quote post" + }, + "messageQuotedPostBlocked": "Quoted post is blocked", + "@messageQuotedPostBlocked": { + "description": "Quoted embed unavailable message for blocked quoted posts" + }, + "messageQuotedPostNotFound": "Quoted post not found", + "@messageQuotedPostNotFound": { + "description": "Quoted embed unavailable message for missing quoted posts" + }, + "messageQuotedPostUnavailable": "Quoted post is unavailable", + "@messageQuotedPostUnavailable": { + "description": "Quoted embed unavailable message for detached or unavailable quoted posts" + }, + "messageRemoveRepostSubtitle": "Remove this repost", + "@messageRemoveRepostSubtitle": { + "description": "Post action subtitle for removing a repost" + }, + "messageReplyInThread": "Reply in a thread", + "@messageReplyInThread": { + "description": "Post card reply context label when parent post details are unavailable" + }, + "messageReplyingTo": "Replying to", + "@messageReplyingTo": { + "description": "Compose reply banner prefix before the replied-to handle" + }, + "messageShareThisPostSubtitle": "Share this post", + "@messageShareThisPostSubtitle": { + "description": "Post action subtitle for reposting a post" + }, + "messageShowLikedUsersSubtitle": "View who liked this post", + "@messageShowLikedUsersSubtitle": { + "description": "Post overflow subtitle for viewing users who liked a post" + }, + "messageShowQuoteRepostListSubtitle": "View quote posts and expand reposts", + "@messageShowQuoteRepostListSubtitle": { + "description": "Post overflow subtitle for viewing quotes and reposts" + }, + "messageRefreshProviderHealthSubtitle": "Probe public AppView endpoints now", + "@messageRefreshProviderHealthSubtitle": { + "description": "Settings subtitle for refreshing provider health" + }, + "messageResetSignInDataSubtitle": "Troubleshoot OAuth or account-switching issues by clearing local sessions on this device", + "@messageResetSignInDataSubtitle": { + "description": "Settings subtitle for resetting sign-in data" + }, + "messageSearchSubtitle": "Search", + "@messageSearchSubtitle": { + "description": "Generic search subtitle" + }, + "messageClearSearchHistoryContent": "This will delete all your recent searches.", + "@messageClearSearchHistoryContent": { + "description": "Confirmation dialog body before clearing search history" + }, + "messageClearSearchHistoryTitle": "Clear search history?", + "@messageClearSearchHistoryTitle": { + "description": "Confirmation dialog title before clearing search history" + }, + "messageStartTypingToSearchHandles": "Start typing to search handles.", + "@messageStartTypingToSearchHandles": { + "description": "Hint shown in jump to profile dialog" + }, + "messageStarterPackSearchApiUnavailable": "Starter pack search is not available in the API yet.", + "@messageStarterPackSearchApiUnavailable": { + "description": "Helper text when starter pack search is disabled" + }, + "messageStarterPackSearchUnavailableTitle": "Starter Pack Search Is Unavailable", + "@messageStarterPackSearchUnavailableTitle": { + "description": "Starter pack search unavailable state title" + }, + "messageStarterPackSearchUnavailableBody": "(Starter Pack Search is not yet implemented in the BlueSky API)", + "@messageStarterPackSearchUnavailableBody": { + "description": "Starter pack search unavailable state body" + }, + "messageTrackApiProgress": "Track API progress", + "@messageTrackApiProgress": { + "description": "Button label to open upstream API issue" + }, + "messageCouldNotOpenIssueLink": "Could not open issue link.", + "@messageCouldNotOpenIssueLink": { + "description": "Snackbar message when opening upstream issue link fails" + }, + "messageSearchPostsPlaceholder": "Search posts", + "@messageSearchPostsPlaceholder": { + "description": "Search posts field placeholder" + }, + "messageSearchThisProfilesPostsPlaceholder": "Search this profile''s posts", + "@messageSearchThisProfilesPostsPlaceholder": { + "description": "Search placeholder in profile posts-only mode" + }, + "messageSearchPeoplePlaceholder": "Search people", + "@messageSearchPeoplePlaceholder": { + "description": "Search people field placeholder" + }, + "messageSearchForPeoplePlaceholder": "Search for people", + "@messageSearchForPeoplePlaceholder": { + "description": "Search field placeholder when adding list members" + }, + "messageSearchFeedsPlaceholder": "Search feeds", + "@messageSearchFeedsPlaceholder": { + "description": "Search feeds field placeholder" + }, + "messageStarterPackSearchUnavailablePlaceholder": "Starter pack search unavailable", + "@messageStarterPackSearchUnavailablePlaceholder": { + "description": "Starter pack search field placeholder" + }, + "messageSlingshotIdentityFallbackSubtitle": "If handle lookup fails, use Slingshot to find your DID and PDS so sign-in can continue", "@messageSlingshotIdentityFallbackSubtitle": { "description": "Settings subtitle for Slingshot identity fallback" }, @@ -769,8 +1567,1800 @@ "@promptHandleOrDid": { "description": "Label for handle or DID sign-in field" }, - "validationEnterAppPassword": "Enter your app password", - "@validationEnterAppPassword": { - "description": "Validation error for missing app password" + "buttonAddFeed": "Add feed", + "@buttonAddFeed": { + "description": "Button label to add a feed" + }, + "buttonAddMembers": "Add members", + "@buttonAddMembers": { + "description": "Button label to add members to a list" + }, + "buttonBlock": "Block", + "@buttonBlock": { + "description": "Button label to block an account" + }, + "buttonCreate": "Create", + "@buttonCreate": { + "description": "Create button label" + }, + "buttonEdit": "Edit", + "@buttonEdit": { + "description": "Edit button label" + }, + "buttonFollow": "Follow", + "@buttonFollow": { + "description": "Button label to follow an account" + }, + "buttonFollowAll": "Follow all", + "@buttonFollowAll": { + "description": "Button label to follow all starter pack members" + }, + "buttonFollowing": "Following", + "@buttonFollowing": { + "description": "Button label showing that an account is followed" + }, + "buttonFollowingInProgress": "Following…", + "@buttonFollowingInProgress": { + "description": "Button label while following all starter pack members" + }, + "buttonLoadMore": "Load more", + "@buttonLoadMore": { + "description": "Button label to load more results" + }, + "buttonMute": "Mute", + "@buttonMute": { + "description": "Button label to mute an account or list" + }, + "buttonScan": "Scan", + "@buttonScan": { + "description": "Button label to start a follow audit scan" + }, + "buttonSeeAll": "See all", + "@buttonSeeAll": { + "description": "Button label to view all items" + }, + "buttonShowAccounts": "Show accounts", + "@buttonShowAccounts": { + "description": "Button label to reveal blocked-by accounts" + }, + "buttonSubmitReport": "Submit Report", + "@buttonSubmitReport": { + "description": "Button label to submit a moderation report" + }, + "buttonUnblock": "Unblock", + "@buttonUnblock": { + "description": "Button label to unblock an account" + }, + "buttonUnfollow": "Unfollow", + "@buttonUnfollow": { + "description": "Button label to unfollow an account" + }, + "buttonUnfollowSelected": "Unfollow Selected ({count})", + "@buttonUnfollowSelected": { + "description": "Button label to unfollow selected audit results", + "placeholders": { + "count": { + "type": "int", + "example": "3" + } + } + }, + "buttonUnmute": "Unmute", + "@buttonUnmute": { + "description": "Button label to unmute an account or list" + }, + "dialogBlockAccountContent": "They will not be able to see your posts or interact with you. They will not be notified that you blocked them.", + "@dialogBlockAccountContent": { + "description": "Confirmation dialog body before blocking an account" + }, + "dialogBlockAccountTitle": "Block Account?", + "@dialogBlockAccountTitle": { + "description": "Confirmation dialog title before blocking an account" + }, + "dialogDeleteListTitle": "Delete list?", + "@dialogDeleteListTitle": { + "description": "Confirmation dialog title before deleting a list" + }, + "dialogDeleteStarterPackContent": "This will permanently delete this starter pack and its backing list. This cannot be undone.", + "@dialogDeleteStarterPackContent": { + "description": "Confirmation dialog body before deleting a starter pack" + }, + "dialogDeleteStarterPackTitle": "Delete starter pack", + "@dialogDeleteStarterPackTitle": { + "description": "Confirmation dialog title before deleting a starter pack" + }, + "dialogMuteAccountContent": "You will no longer see their posts or receive notifications from them.", + "@dialogMuteAccountContent": { + "description": "Confirmation dialog body before muting an account" + }, + "dialogMuteAccountTitle": "Mute Account?", + "@dialogMuteAccountTitle": { + "description": "Confirmation dialog title before muting an account" + }, + "dialogUnblockAccountContent": "They will be able to see your posts and interact with you again.", + "@dialogUnblockAccountContent": { + "description": "Confirmation dialog body before unblocking an account" + }, + "dialogUnblockAccountTitle": "Unblock Account?", + "@dialogUnblockAccountTitle": { + "description": "Confirmation dialog title before unblocking an account" + }, + "dialogUnfollowAccountContent": "You will no longer see their posts in your feed.", + "@dialogUnfollowAccountContent": { + "description": "Confirmation dialog body before unfollowing an account" + }, + "dialogUnfollowAccountTitle": "Unfollow?", + "@dialogUnfollowAccountTitle": { + "description": "Confirmation dialog title before unfollowing an account" + }, + "dialogUnmuteAccountContent": "You will see their posts and receive notifications again.", + "@dialogUnmuteAccountContent": { + "description": "Confirmation dialog body before unmuting an account" + }, + "dialogUnmuteAccountTitle": "Unmute Account?", + "@dialogUnmuteAccountTitle": { + "description": "Confirmation dialog title before unmuting an account" + }, + "errorFailedToCreateStarterPack": "Failed to create starter pack", + "@errorFailedToCreateStarterPack": { + "description": "Error message when starter pack creation fails" + }, + "errorFailedToLoadAccounts": "Failed to load accounts", + "@errorFailedToLoadAccounts": { + "description": "Error message when accounts cannot load" + }, + "errorFailedToLoadFeed": "Failed to load feed", + "@errorFailedToLoadFeed": { + "description": "Error message when a list feed cannot load" + }, + "errorFailedToLoadFeeds": "Failed to load feeds", + "@errorFailedToLoadFeeds": { + "description": "Error message when feed picker suggestions cannot load" + }, + "errorFailedToLoadList": "Failed to load list", + "@errorFailedToLoadList": { + "description": "Error message when a list cannot load" + }, + "errorFailedToLoadLists": "Failed to load lists", + "@errorFailedToLoadLists": { + "description": "Error message when lists cannot load" + }, + "errorFailedToLoadMembers": "Failed to load members", + "@errorFailedToLoadMembers": { + "description": "Error message when list members cannot load" + }, + "errorFailedToLoadMore": "Failed to load more", + "@errorFailedToLoadMore": { + "description": "Error message when loading more results fails" + }, + "errorFailedToLoadPosts": "Failed to load posts", + "@errorFailedToLoadPosts": { + "description": "Error message when posts cannot load" + }, + "errorFailedToLoadProfile": "Unable to load profile", + "@errorFailedToLoadProfile": { + "description": "Error title when a profile cannot load" + }, + "errorFailedToLoadStarterPack": "Failed to load starter pack", + "@errorFailedToLoadStarterPack": { + "description": "Error message when a starter pack cannot load" + }, + "errorFailedToLoadStarterPacks": "Failed to load starter packs", + "@errorFailedToLoadStarterPacks": { + "description": "Error message when starter packs cannot load" + }, + "errorFailedToLoadSuggestions": "Failed to load suggestions", + "@errorFailedToLoadSuggestions": { + "description": "Error title when suggested follows cannot load" + }, + "errorFollowAuditFailed": "Failed to complete follow audit.", + "@errorFollowAuditFailed": { + "description": "Error message when follow audit fails" + }, + "errorImageTooLarge": "Image must be smaller than 1MB", + "@errorImageTooLarge": { + "description": "Validation error when a profile image is too large" + }, + "errorInvalidProfileImageType": "Use a JPEG or PNG image", + "@errorInvalidProfileImageType": { + "description": "Validation error when profile image file type is unsupported" + }, + "errorProfileImageReadFailed": "Unable to read selected image", + "@errorProfileImageReadFailed": { + "description": "Error when a selected profile image cannot be read" + }, + "errorReportFailed": "Unable to submit your report. Please try again later.", + "@errorReportFailed": { + "description": "Report submission failure dialog body" + }, + "errorReportFailedTitle": "Report Failed", + "@errorReportFailedTitle": { + "description": "Report submission failure dialog title" + }, + "errorUnableToLoadConnections": "Unable to load {tab}", + "@errorUnableToLoadConnections": { + "description": "Error title when a profile connection tab cannot load", + "placeholders": { + "tab": { + "type": "String", + "example": "following" + } + } + }, + "errorUnableToUpdateProfile": "Unable to update profile", + "@errorUnableToUpdateProfile": { + "description": "Snackbar message when profile update fails" + }, + "formatAccountCount": "{count, plural, =1{1 account} other{{count} accounts}}", + "@formatAccountCount": { + "description": "Count of accounts", + "placeholders": { + "count": { + "type": "int", + "example": "4" + } + } + }, + "formatBlockedByAccountsUnavailable": "Found {count, plural, =1{1 blocked-by account} other{{count} blocked-by accounts}}, but public Bluesky profile details could not be loaded.", + "@formatBlockedByAccountsUnavailable": { + "description": "Message when blocked-by account count exists but profiles are unavailable", + "placeholders": { + "count": { + "type": "int", + "example": "12" + } + } + }, + "formatConnectionsLoading": "Loading {tab}...", + "@formatConnectionsLoading": { + "description": "Loading message for a profile connection tab", + "placeholders": { + "tab": { + "type": "String", + "example": "followers" + } + } + }, + "formatConnectionsNoMatches": "No {tab} match \"{query}\"", + "@formatConnectionsNoMatches": { + "description": "Empty search message for a profile connection tab", + "placeholders": { + "tab": { + "type": "String", + "example": "followers" + }, + "query": { + "type": "String", + "example": "alice" + } + } + }, + "formatConnectionsNoneFound": "No {tab} found", + "@formatConnectionsNoneFound": { + "description": "Empty message for a profile connection tab", + "placeholders": { + "tab": { + "type": "String", + "example": "followers" + } + } + }, + "formatConnectionsSearching": "Searching {count} accounts...", + "@formatConnectionsSearching": { + "description": "Search progress message for profile connections", + "placeholders": { + "count": { + "type": "int", + "example": "25" + } + } + }, + "formatConnectionsSearched": "Searched {count} accounts", + "@formatConnectionsSearched": { + "description": "Completed search progress message for profile connections", + "placeholders": { + "count": { + "type": "int", + "example": "25" + } + } + }, + "formatConnectionsSearchStopped": "Search stopped after {count} accounts", + "@formatConnectionsSearchStopped": { + "description": "Stopped search progress message for profile connections", + "placeholders": { + "count": { + "type": "int", + "example": "25" + } + } + }, + "formatClassifyingProgress": "Classifying: {progress}/{total}", + "@formatClassifyingProgress": { + "description": "Follow audit classifying progress label", + "placeholders": { + "progress": { + "type": "int", + "example": "20" + }, + "total": { + "type": "int", + "example": "120" + } + } + }, + "formatDidCopied": "DID copied to clipboard", + "@formatDidCopied": { + "description": "Snackbar message after copying a DID" + }, + "formatFetchingFollowsProgress": "Fetching follows: {progress}/{total}", + "@formatFetchingFollowsProgress": { + "description": "Follow audit fetching progress label", + "placeholders": { + "progress": { + "type": "int", + "example": "20" + }, + "total": { + "type": "int", + "example": "120" + } + } + }, + "formatFollowedMemberCount": "Followed {count, plural, =1{1 member} other{{count} members}}", + "@formatFollowedMemberCount": { + "description": "Snackbar message after following starter pack members", + "placeholders": { + "count": { + "type": "int", + "example": "8" + } + } + }, + "formatFollowsScanned": "{count, plural, =1{1 follow scanned for problematic accounts} other{{count} follows scanned for problematic accounts}}", + "@formatFollowsScanned": { + "description": "Follow audit summary after scanning follows", + "placeholders": { + "count": { + "type": "int", + "example": "245" + } + } + }, + "formatHideStatus": "Hide {status}", + "@formatHideStatus": { + "description": "Tooltip to hide a follow audit status", + "placeholders": { + "status": { + "type": "String", + "example": "Deleted" + } + } + }, + "formatJoinedDate": "Joined {date}", + "@formatJoinedDate": { + "description": "Profile joined date label", + "placeholders": { + "date": { + "type": "String", + "example": "March 2026" + } + } + }, + "formatJoinedRelative": "Joined {relativeTime}", + "@formatJoinedRelative": { + "description": "Profile card joined relative time label", + "placeholders": { + "relativeTime": { + "type": "String", + "example": "2mo ago" + } + } + }, + "formatListByHandle": "by @{handle}", + "@formatListByHandle": { + "description": "List creator attribution label", + "placeholders": { + "handle": { + "type": "String", + "example": "alice.bsky.social" + } + } + }, + "formatMemberCount": "{count, plural, =1{1 member} other{{count} members}}", + "@formatMemberCount": { + "description": "Count of list or starter pack members", + "placeholders": { + "count": { + "type": "int", + "example": "12" + } + } + }, + "formatProfileReportTitle": "{title} by @{handle}", + "@formatProfileReportTitle": { + "description": "Report dialog title for a target handle", + "placeholders": { + "title": { + "type": "String", + "example": "Report Post" + }, + "handle": { + "type": "String", + "example": "alice.bsky.social" + } + } + }, + "formatProfileTextLimit": "{label} must be {count} characters or fewer", + "@formatProfileTextLimit": { + "description": "Profile edit validation message for a text length limit", + "placeholders": { + "label": { + "type": "String", + "example": "Display name" + }, + "count": { + "type": "int", + "example": "64" + } + } + }, + "formatProfileTextTooLong": "{label} is too long", + "@formatProfileTextTooLong": { + "description": "Profile edit validation message for byte length", + "placeholders": { + "label": { + "type": "String", + "example": "Description" + } + } + }, + "formatProfilesFailedToLoad": "{count, plural, =1{1 profile could not be loaded.} other{{count} profiles could not be loaded.}}", + "@formatProfilesFailedToLoad": { + "description": "Follow audit warning when some profiles failed to load", + "placeholders": { + "count": { + "type": "int", + "example": "3" + } + } + }, + "formatReportSubmitted": "Thank you. Your report (ID: {reportId}) has been submitted.", + "@formatReportSubmitted": { + "description": "Report submission success dialog body", + "placeholders": { + "reportId": { + "type": "String", + "example": "3lkgvbn" + } + } + }, + "formatSelectedCount": "Selected: {selected}/{total}", + "@formatSelectedCount": { + "description": "Follow audit selected count footer", + "placeholders": { + "selected": { + "type": "int", + "example": "2" + }, + "total": { + "type": "int", + "example": "12" + } + } + }, + "formatShowStatus": "Show {status}", + "@formatShowStatus": { + "description": "Tooltip to show a follow audit status", + "placeholders": { + "status": { + "type": "String", + "example": "Deleted" + } + } + }, + "formatUnavailableAccounts": "Unavailable accounts ({count})", + "@formatUnavailableAccounts": { + "description": "Title for unavailable accounts card", + "placeholders": { + "count": { + "type": "int", + "example": "3" + } + } + }, + "formatUnfollowedAccounts": "Unfollowed {count} account(s)", + "@formatUnfollowedAccounts": { + "description": "Follow audit completion message", + "placeholders": { + "count": { + "type": "int", + "example": "5" + } + } + }, + "formatValidationRequiredMaxCharacters": "Required, max {count} characters", + "@formatValidationRequiredMaxCharacters": { + "description": "Helper text for required character-limited fields", + "placeholders": { + "count": { + "type": "int", + "example": "50" + } + } + }, + "labelAddToList": "Add to list", + "@labelAddToList": { + "description": "Action label to add an account to a list" + }, + "labelAuditFollowers": "Audit Followers", + "@labelAuditFollowers": { + "description": "Follow audit screen title" + }, + "labelBanner": "Banner", + "@labelBanner": { + "description": "Profile banner image button label" + }, + "labelBlockViaList": "Block via list", + "@labelBlockViaList": { + "description": "List action label to block accounts via a moderation list" + }, + "labelBlockedBy": "Blocked By", + "@labelBlockedBy": { + "description": "Profile context tab label for accounts that blocked the user" + }, + "labelBlocking": "Blocking", + "@labelBlocking": { + "description": "Profile context tab label for accounts the user is blocking" + }, + "labelConnections": "Connections", + "@labelConnections": { + "description": "Profile connections screen title" + }, + "labelCopyDid": "Copy DID", + "@labelCopyDid": { + "description": "Profile action label to copy DID" + }, + "labelCreateList": "Create list", + "@labelCreateList": { + "description": "Create list dialog title" + }, + "labelCreateStarterPack": "Create starter pack", + "@labelCreateStarterPack": { + "description": "Tooltip to create a starter pack" + }, + "labelCurateShort": "CURATE", + "@labelCurateShort": { + "description": "Short curation list badge label" + }, + "labelCurrentMembers": "Current Members", + "@labelCurrentMembers": { + "description": "List members section heading" + }, + "labelCurationLists": "Curation Lists", + "@labelCurationLists": { + "description": "Profile context list section heading" + }, + "labelDescription": "Description", + "@labelDescription": { + "description": "Description field label" + }, + "labelDescriptionOptional": "Description (optional)", + "@labelDescriptionOptional": { + "description": "Optional description field label" + }, + "labelDisplayName": "Display name", + "@labelDisplayName": { + "description": "Profile display name field label" + }, + "labelEditList": "Edit list", + "@labelEditList": { + "description": "Edit list dialog or action title" + }, + "labelEditProfile": "Edit profile", + "@labelEditProfile": { + "description": "Edit profile screen title or action label" + }, + "labelEditStarterPack": "Edit starter pack", + "@labelEditStarterPack": { + "description": "Edit starter pack dialog title" + }, + "labelFeed": "Feed", + "@labelFeed": { + "description": "Feed label" + }, + "labelFollowers": "Followers", + "@labelFollowers": { + "description": "Followers label" + }, + "labelJoinedThisWeek": "joined this week", + "@labelJoinedThisWeek": { + "description": "Starter pack statistic label for joins this week" + }, + "labelJoinedTotal": "joined total", + "@labelJoinedTotal": { + "description": "Starter pack card statistic label for total joins" + }, + "labelFollowing": "Following", + "@labelFollowing": { + "description": "Following label" + }, + "labelList": "List", + "@labelList": { + "description": "Generic list title" + }, + "labelLists": "Lists", + "@labelLists": { + "description": "Lists label" + }, + "labelMedia": "Media", + "@labelMedia": { + "description": "Profile media tab label" + }, + "labelMembers": "Members", + "@labelMembers": { + "description": "Members section label" + }, + "labelModerationLists": "Moderation Lists", + "@labelModerationLists": { + "description": "Profile context moderation list section heading" + }, + "labelModerationShort": "MOD", + "@labelModerationShort": { + "description": "Short moderation list badge label" + }, + "labelMuteList": "Mute list", + "@labelMuteList": { + "description": "List action label to mute a list" + }, + "labelMutuals": "Mutuals", + "@labelMutuals": { + "description": "Mutual follows label" + }, + "labelMyLists": "My Lists", + "@labelMyLists": { + "description": "My lists screen title" + }, + "labelName": "Name", + "@labelName": { + "description": "Name field label" + }, + "labelNewStarterPack": "New Starter Pack", + "@labelNewStarterPack": { + "description": "New starter pack screen title" + }, + "labelOtherLists": "Other Lists", + "@labelOtherLists": { + "description": "Profile context other list section heading" + }, + "labelPronouns": "Pronouns", + "@labelPronouns": { + "description": "Pronouns field label" + }, + "labelProfileContext": "Profile Context", + "@labelProfileContext": { + "description": "Profile context screen title and action label" + }, + "labelProfileTitle": "Profile", + "@labelProfileTitle": { + "description": "Generic profile screen title" + }, + "labelRecommendedFeeds": "Recommended Feeds", + "@labelRecommendedFeeds": { + "description": "Starter pack feeds section title" + }, + "labelReferenceLists": "Reference Lists", + "@labelReferenceLists": { + "description": "Profile context reference list section heading" + }, + "labelReferenceShort": "REFERENCE", + "@labelReferenceShort": { + "description": "Short reference list badge label" + }, + "labelReplies": "Replies", + "@labelReplies": { + "description": "Profile replies tab label" + }, + "labelReport": "Report", + "@labelReport": { + "description": "Report action label" + }, + "labelReportAccount": "Report Account", + "@labelReportAccount": { + "description": "Report account dialog title" + }, + "labelReportReason": "Reason", + "@labelReportReason": { + "description": "Report reason section label" + }, + "labelReportReasonExplanationRequired": "Explanation (required)", + "@labelReportReasonExplanationRequired": { + "description": "Report explanation field label" + }, + "labelReportReasonHarassment": "Harassment", + "@labelReportReasonHarassment": { + "description": "Report reason label for harassment" + }, + "labelReportReasonMisleading": "Misleading", + "@labelReportReasonMisleading": { + "description": "Report reason label for misleading content" + }, + "labelReportReasonOther": "Other", + "@labelReportReasonOther": { + "description": "Report reason label for other" + }, + "labelReportReasonSexualContent": "Sexual Content", + "@labelReportReasonSexualContent": { + "description": "Report reason label for sexual content" + }, + "labelReportReasonSpam": "Spam", + "@labelReportReasonSpam": { + "description": "Report reason label for spam" + }, + "labelReportReasonViolation": "Violation", + "@labelReportReasonViolation": { + "description": "Report reason label for violations" + }, + "labelReportSubmitted": "Report Submitted", + "@labelReportSubmitted": { + "description": "Report success dialog title" + }, + "labelSelectAll": "Select All", + "@labelSelectAll": { + "description": "Select all checkbox label" + }, + "labelSelectFeed": "Select a feed", + "@labelSelectFeed": { + "description": "Feed picker sheet title" + }, + "labelShareProfile": "Share Profile", + "@labelShareProfile": { + "description": "Profile action label to share a profile" + }, + "labelStarterPack": "Starter Pack", + "@labelStarterPack": { + "description": "Fallback starter pack title" + }, + "labelType": "Type", + "@labelType": { + "description": "Type field label" + }, + "labelTotalJoined": "total joined", + "@labelTotalJoined": { + "description": "Starter pack detail statistic label for total joins" + }, + "labelUnavailableLikedPost": "Unavailable liked post", + "@labelUnavailableLikedPost": { + "description": "Profile liked posts unavailable entry title" + }, + "labelUnblockViaList": "Unblock via list", + "@labelUnblockViaList": { + "description": "List action label to unblock accounts via a moderation list" + }, + "labelUnmuteList": "Unmute list", + "@labelUnmuteList": { + "description": "List action label to unmute a list" + }, + "labelUpToThree": "(up to 3)", + "@labelUpToThree": { + "description": "Helper label for selecting up to three starter pack feeds" + }, + "labelWebsite": "Website", + "@labelWebsite": { + "description": "Website field label" + }, + "labelYou": "You", + "@labelYou": { + "description": "Profile connection chip for the current user" + }, + "messageBlockedByContextNotice": "Blocks are a normal part of social media. This data is public on the AT Protocol.", + "@messageBlockedByContextNotice": { + "description": "Profile context blocked-by explanatory copy" + }, + "messageBlockingOnlyOwnProfile": "Blocking information is only available when viewing your own profile.", + "@messageBlockingOnlyOwnProfile": { + "description": "Profile context message when blocking list is not available" + }, + "messageChangeAvatarImage": "Change avatar image", + "@messageChangeAvatarImage": { + "description": "Tooltip and semantics label for changing profile avatar" + }, + "messageChangeBannerImage": "Change banner image", + "@messageChangeBannerImage": { + "description": "Tooltip and semantics label for changing profile banner" + }, + "messageEnterValidWebsite": "Enter a valid website", + "@messageEnterValidWebsite": { + "description": "Profile edit validation error for invalid website" + }, + "messageFeedUnavailableForModerationLists": "Feed not available for moderation lists", + "@messageFeedUnavailableForModerationLists": { + "description": "List detail message when moderation lists do not have feeds" + }, + "messageFollowAuditIntro": "Scan your follows for deleted, suspended, blocked, and hidden accounts.", + "@messageFollowAuditIntro": { + "description": "Follow audit intro message before scanning" + }, + "messageFollowAuditStartPrompt": "Tap Scan to audit your follow list.", + "@messageFollowAuditStartPrompt": { + "description": "Follow audit empty prompt before scanning" + }, + "messageNoAccountsBlockedThisUser": "No accounts have blocked this user", + "@messageNoAccountsBlockedThisUser": { + "description": "Profile context empty blocked-by message" + }, + "messageNoListsYet": "No lists yet", + "@messageNoListsYet": { + "description": "Empty state when no lists exist" + }, + "messageNoMembers": "No members", + "@messageNoMembers": { + "description": "Empty starter pack members message" + }, + "messageNoMembersYet": "No members yet", + "@messageNoMembersYet": { + "description": "Empty list members message" + }, + "messageNoMembersYetSearch": "No members yet. Search above to add people.", + "@messageNoMembersYetSearch": { + "description": "Empty list members message with search instruction" + }, + "messageNoMediaPostsYet": "No media posts yet", + "@messageNoMediaPostsYet": { + "description": "Empty profile media tab message" + }, + "messageNoPostsYet": "No posts yet", + "@messageNoPostsYet": { + "description": "Empty profile/list feed message" + }, + "messageNoProblematicFollows": "No problematic follows found", + "@messageNoProblematicFollows": { + "description": "Follow audit empty results message" + }, + "messageNoRepliesYet": "No replies yet", + "@messageNoRepliesYet": { + "description": "Empty profile replies message" + }, + "messageNoResultsForFilters": "No results visible for the current filters.", + "@messageNoResultsForFilters": { + "description": "Follow audit empty filtered results message" + }, + "messageNoStarterPacksYet": "No starter packs yet", + "@messageNoStarterPacksYet": { + "description": "Empty starter packs message" + }, + "messageNoSuggestionsFound": "No suggestions found", + "@messageNoSuggestionsFound": { + "description": "Empty suggested follows message" + }, + "messageNotBlockingAnyone": "Not blocking anyone", + "@messageNotBlockingAnyone": { + "description": "Profile context empty blocking message" + }, + "messageNotOnAnyLists": "Not on any lists", + "@messageNotOnAnyLists": { + "description": "Profile context empty lists-on message" + }, + "messageProfileUnavailable": "Profile unavailable", + "@messageProfileUnavailable": { + "description": "Fallback unavailable profile reason" + }, + "messageProfileUpdated": "Profile updated", + "@messageProfileUpdated": { + "description": "Snackbar after profile update succeeds" + }, + "messageReportExplanationHint": "Please explain why you are reporting this...", + "@messageReportExplanationHint": { + "description": "Report explanation text field hint" + }, + "messageReportReasonHarassmentDescription": "Harassment or rude behaviour", + "@messageReportReasonHarassmentDescription": { + "description": "Report reason description for harassment" + }, + "messageReportReasonMisleadingDescription": "Misleading or deceptive content", + "@messageReportReasonMisleadingDescription": { + "description": "Report reason description for misleading content" + }, + "messageReportReasonOtherDescription": "Other reason (requires explanation)", + "@messageReportReasonOtherDescription": { + "description": "Report reason description for other" + }, + "messageReportReasonSexualContentDescription": "Unwanted sexual content", + "@messageReportReasonSexualContentDescription": { + "description": "Report reason description for sexual content" + }, + "messageReportReasonSpamDescription": "Spam or unsolicited content", + "@messageReportReasonSpamDescription": { + "description": "Report reason description for spam" + }, + "messageReportReasonViolationDescription": "Violates community guidelines", + "@messageReportReasonViolationDescription": { + "description": "Report reason description for violations" + }, + "messageSearchConnectionsPlaceholder": "Search handle, name, or description", + "@messageSearchConnectionsPlaceholder": { + "description": "Profile connections search field placeholder" + }, + "messageSearchPeopleToAddPlaceholder": "Search for people to add", + "@messageSearchPeopleToAddPlaceholder": { + "description": "Search field placeholder when adding people" + }, + "messageSomeBlockedAccountsUnavailable": "Some blocked accounts are suspended or unavailable.", + "@messageSomeBlockedAccountsUnavailable": { + "description": "Profile context message when some blocked accounts are unavailable" + }, + "messageSuggestedFollowsUnavailable": "Suggested follows are unavailable right now.", + "@messageSuggestedFollowsUnavailable": { + "description": "Suggested follows unavailable message" + }, + "messageUnavailableAccountsDescription": "These accounts are suspended or their public profile could not be fetched.", + "@messageUnavailableAccountsDescription": { + "description": "Unavailable accounts card subtitle" + }, + "statusBlockedBy": "Blocked by", + "@statusBlockedBy": { + "description": "Follow audit status label" + }, + "statusBlocking": "Blocking", + "@statusBlocking": { + "description": "Follow audit status label" + }, + "statusDeactivated": "Deactivated", + "@statusDeactivated": { + "description": "Follow audit status label" + }, + "statusDeleted": "Deleted", + "@statusDeleted": { + "description": "Follow audit status label" + }, + "statusHidden": "Hidden", + "@statusHidden": { + "description": "Follow audit status label" + }, + "statusMutualBlock": "Mutual block", + "@statusMutualBlock": { + "description": "Follow audit status label" + }, + "statusSelfFollow": "Self-follow", + "@statusSelfFollow": { + "description": "Follow audit status label" + }, + "statusSuspended": "Suspended", + "@statusSuspended": { + "description": "Follow audit status label" + }, + "tooltipClearSearch": "Clear search", + "@tooltipClearSearch": { + "description": "Tooltip for clearing a search field" + }, + "tooltipJumpToTop": "Jump to top", + "@tooltipJumpToTop": { + "description": "Tooltip for jump to top button" + }, + "validationEnterValidWebsite": "Enter a valid website", + "@validationEnterValidWebsite": { + "description": "Profile edit validation error for invalid website" + }, + "accountSwitcherNoOtherAccounts": "No other signed-in accounts yet. Add an account to switch between profiles.", + "@accountSwitcherNoOtherAccounts": { + "description": "Empty state message in the account switcher sheet" + }, + "buttonAddAccount": "Add Account", + "@buttonAddAccount": { + "description": "Button and dialog title to add another account" + }, + "buttonCopyAll": "Copy All", + "@buttonCopyAll": { + "description": "Message thread menu item to copy all messages" + }, + "buttonMarkAllRead": "Mark All Read", + "@buttonMarkAllRead": { + "description": "Button label to mark notifications read" + }, + "errorFailedToAddAccount": "Failed to add account", + "@errorFailedToAddAccount": { + "description": "Snackbar message when adding an account fails" + }, + "errorFailedToLoadMessages": "Failed to load messages", + "@errorFailedToLoadMessages": { + "description": "Error title when messages cannot load" + }, + "errorFailedToLoadNotifications": "Failed to load notifications", + "@errorFailedToLoadNotifications": { + "description": "Error title when notifications cannot load" + }, + "errorUnableToRemoveAccountNow": "Unable to remove account right now.", + "@errorUnableToRemoveAccountNow": { + "description": "Snackbar message when removing an account from account switcher fails" + }, + "formatActorListTwo": "{first} and {second}", + "@formatActorListTwo": { + "description": "Two-person actor summary in grouped notifications", + "placeholders": { + "first": { + "type": "String", + "example": "Alice" + }, + "second": { + "type": "String", + "example": "Bob" + } + } + }, + "formatActorListWithOthers": "{first}, {second}, and {count} others", + "@formatActorListWithOthers": { + "description": "Multi-person actor summary in grouped notifications", + "placeholders": { + "first": { + "type": "String", + "example": "Alice" + }, + "second": { + "type": "String", + "example": "Bob" + }, + "count": { + "type": "int", + "example": "3" + } + } + }, + "formatMonthDay": "{month} {day}", + "@formatMonthDay": { + "description": "Month and day label for notification sections", + "placeholders": { + "month": { + "type": "String", + "example": "January" + }, + "day": { + "type": "int", + "example": "8" + } + } + }, + "formatRemoveAccountContent": "Remove @{handle} from this device?", + "@formatRemoveAccountContent": { + "description": "Confirmation dialog body when removing an account from the account switcher", + "placeholders": { + "handle": { + "type": "String", + "example": "alice.bsky.social" + } + } + }, + "labelAccounts": "Accounts", + "@labelAccounts": { + "description": "Account switcher sheet title" + }, + "labelAlertsTitle": "Alerts", + "@labelAlertsTitle": { + "description": "Alerts screen title" + }, + "labelConversation": "Conversation", + "@labelConversation": { + "description": "Fallback conversation title" + }, + "labelFollows": "Follows", + "@labelFollows": { + "description": "Notification channel name for follows" + }, + "labelLikes": "Likes", + "@labelLikes": { + "description": "Notification channel name for likes" + }, + "labelMessageRequests": "Requests", + "@labelMessageRequests": { + "description": "Message requests tab label" + }, + "labelOther": "Other", + "@labelOther": { + "description": "Other notification channel name" + }, + "labelPrimary": "Primary", + "@labelPrimary": { + "description": "Primary messages tab label" + }, + "labelSomeone": "Someone", + "@labelSomeone": { + "description": "Fallback actor summary for grouped notifications" + }, + "messageCopied": "Message copied", + "@messageCopied": { + "description": "Snackbar message after copying a message" + }, + "messageDeleted": "Message deleted", + "@messageDeleted": { + "description": "Deleted message placeholder" + }, + "messageLocalNotificationFallbackBody": "sent a notification", + "@messageLocalNotificationFallbackBody": { + "description": "Local notification body fallback for unknown notification reasons" + }, + "messageNewNotification": "New notification", + "@messageNewNotification": { + "description": "Local notification title fallback" + }, + "messageNoConnection": "No connection", + "@messageNoConnection": { + "description": "Offline state title" + }, + "messageNoConversationsYet": "No conversations yet", + "@messageNoConversationsYet": { + "description": "Empty primary conversations state" + }, + "messageNoMessageRequests": "No message requests", + "@messageNoMessageRequests": { + "description": "Empty message requests state" + }, + "messageNoMessagesYet": "No messages yet", + "@messageNoMessagesYet": { + "description": "Empty message thread state" + }, + "messageNoNotificationsYet": "No notifications yet", + "@messageNoNotificationsYet": { + "description": "Empty notifications state" + }, + "messageNotificationContactMatch": "joined from your contacts", + "@messageNotificationContactMatch": { + "description": "Notification summary for contact match" + }, + "messageNotificationFollow": "followed you", + "@messageNotificationFollow": { + "description": "Notification summary for follows" + }, + "messageNotificationInteracted": "interacted with you", + "@messageNotificationInteracted": { + "description": "Notification summary fallback" + }, + "messageNotificationLike": "liked your post", + "@messageNotificationLike": { + "description": "Notification summary for likes" + }, + "messageNotificationLikeViaRepost": "liked your repost", + "@messageNotificationLikeViaRepost": { + "description": "Notification summary for likes via repost" + }, + "messageNotificationMention": "mentioned you", + "@messageNotificationMention": { + "description": "Notification summary for mentions" + }, + "messageNotificationQuote": "quoted your post", + "@messageNotificationQuote": { + "description": "Notification summary for quotes" + }, + "messageNotificationReply": "replied to your post", + "@messageNotificationReply": { + "description": "Notification summary for replies" + }, + "messageNotificationRepost": "reposted your post", + "@messageNotificationRepost": { + "description": "Notification summary for reposts" + }, + "messageNotificationRepostViaRepost": "reposted your repost", + "@messageNotificationRepostViaRepost": { + "description": "Notification summary for reposts via repost" + }, + "messageNotificationStarterPackJoined": "joined via your starter pack", + "@messageNotificationStarterPackJoined": { + "description": "Notification summary for starter pack joins" + }, + "messageNotificationSubscribedPost": "posted a new update", + "@messageNotificationSubscribedPost": { + "description": "Notification summary for subscribed posts" + }, + "messageNotificationUnverified": "removed your verification", + "@messageNotificationUnverified": { + "description": "Notification summary for unverified account" + }, + "messageNotificationVerified": "verified your account", + "@messageNotificationVerified": { + "description": "Notification summary for verified account" + }, + "messagePlaceholder": "Message…", + "@messagePlaceholder": { + "description": "Message composer placeholder" + }, + "messagePleaseSignInAgainForAccount": "Please sign in again for that account.", + "@messagePleaseSignInAgainForAccount": { + "description": "Snackbar when account switch requires reauthentication" + }, + "messageReconnectToLoadMessages": "Reconnect to load messages.", + "@messageReconnectToLoadMessages": { + "description": "Offline message list explanation" + }, + "messageReconnectToLoadNotifications": "Reconnect to load notifications.", + "@messageReconnectToLoadNotifications": { + "description": "Offline notifications explanation" + }, + "messageThreadCopied": "Thread copied", + "@messageThreadCopied": { + "description": "Snackbar after copying a message thread" + }, + "messageToday": "Today", + "@messageToday": { + "description": "Today date section label" + }, + "messageYesterday": "Yesterday", + "@messageYesterday": { + "description": "Yesterday date section label" + }, + "placeholderUsernameBskySocial": "username.bsky.social", + "@placeholderUsernameBskySocial": { + "description": "Handle placeholder in account switcher" + }, + "validationEnterBlueskyHandleOrDid": "Enter a Bluesky handle or DID", + "@validationEnterBlueskyHandleOrDid": { + "description": "Account switcher validation error for empty handle or DID" + }, + "validationEnterCompleteDid": "Enter a complete DID like did:plc:... or did:web:...", + "@validationEnterCompleteDid": { + "description": "Account switcher validation error for incomplete DID" + }, + "validationEnterFullHandle": "Enter a full handle like username.bsky.social", + "@validationEnterFullHandle": { + "description": "Account switcher validation error for invalid handle" + }, + "validationUseSupportedDid": "Use a did:plc:... or did:web:... identifier", + "@validationUseSupportedDid": { + "description": "Account switcher validation error for unsupported DID method" + }, + "validationEnterAppPassword": "Enter your app password", + "@validationEnterAppPassword": { + "description": "Validation error for missing app password" + }, + "buttonAdd": "Add", + "@buttonAdd": { + "description": "Generic add button label" + }, + "buttonAdding": "Adding...", + "@buttonAdding": { + "description": "Button label while an add action is in progress" + }, + "buttonInspiredByPdsLs": "Inspired by pds.ls", + "@buttonInspiredByPdsLs": { + "description": "Devtools button label linking to pds.ls" + }, + "buttonCopyJson": "Copy JSON", + "@buttonCopyJson": { + "description": "Button label to copy record JSON" + }, + "buttonResolve": "Resolve", + "@buttonResolve": { + "description": "Button label to resolve a handle, DID, or AT URI" + }, + "buttonUnsubscribe": "Unsubscribe", + "@buttonUnsubscribe": { + "description": "Button label to unsubscribe from a labeler" + }, + "dialogAddLabelerTitle": "Add labeler", + "@dialogAddLabelerTitle": { + "description": "Dialog title for adding a moderation labeler" + }, + "dialogClearAllLogsContent": "This will permanently delete all log files. This action cannot be undone.", + "@dialogClearAllLogsContent": { + "description": "Confirmation dialog body before clearing all log files" + }, + "dialogClearAllLogsTitle": "Clear all logs?", + "@dialogClearAllLogsTitle": { + "description": "Confirmation dialog title before clearing all log files" + }, + "errorFailedToLoadLogs": "Failed to load logs", + "@errorFailedToLoadLogs": { + "description": "Error title when logs cannot load" + }, + "errorFailedToLoadModerationSettings": "Failed to load moderation settings", + "@errorFailedToLoadModerationSettings": { + "description": "Error title when moderation settings cannot load" + }, + "errorFailedToUnsubscribeLabeler": "Failed to unsubscribe: {error}", + "@errorFailedToUnsubscribeLabeler": { + "description": "Snackbar message when unsubscribing from a labeler fails", + "placeholders": { + "error": { + "type": "Object", + "example": "network unavailable" + } + } + }, + "errorFailedToUpdateAdultContent": "Failed to update adult content: {error}", + "@errorFailedToUpdateAdultContent": { + "description": "Snackbar message when adult content preference update fails", + "placeholders": { + "error": { + "type": "Object", + "example": "network unavailable" + } + } + }, + "errorFailedToUpdateLabelPreference": "Failed to update preference: {error}", + "@errorFailedToUpdateLabelPreference": { + "description": "Snackbar message when a label preference update fails", + "placeholders": { + "error": { + "type": "Object", + "example": "network unavailable" + } + } + }, + "errorFailedToUpdateLabelerSubscription": "Failed to update subscription: {error}", + "@errorFailedToUpdateLabelerSubscription": { + "description": "Snackbar message when a labeler subscription update fails", + "placeholders": { + "error": { + "type": "Object", + "example": "network unavailable" + } + } + }, + "errorLabelerDidRequired": "Enter a labeler DID.", + "@errorLabelerDidRequired": { + "description": "Validation error when adding a labeler without a DID" + }, + "errorLabelerNotFound": "Labeler not found.", + "@errorLabelerNotFound": { + "description": "Error thrown when a labeler DID cannot be loaded" + }, + "errorNoLabelerFoundForDid": "No labeler found for that DID.", + "@errorNoLabelerFoundForDid": { + "description": "Validation error when no labeler exists for the entered DID" + }, + "errorUnableToLoadLabeler": "Unable to load labeler", + "@errorUnableToLoadLabeler": { + "description": "Error title when a labeler detail screen cannot load" + }, + "formatAddLabelerLimit": "Add ({current}/{max})", + "@formatAddLabelerLimit": { + "description": "Add labeler button showing current and maximum custom labelers", + "placeholders": { + "current": { + "type": "int", + "example": "1" + }, + "max": { + "type": "int", + "example": "20" + } + } + }, + "formatCollectionsCount": "{count, plural, =1{1 collection} other{{count} collections}}", + "@formatCollectionsCount": { + "description": "Devtools repository collection count", + "placeholders": { + "count": { + "type": "int", + "example": "2" + } + } + }, + "formatCid": "CID: {cid}", + "@formatCid": { + "description": "Devtools record CID label", + "placeholders": { + "cid": { + "type": "String", + "example": "bafyreicid" + } + } + }, + "formatCustomLabelCount": "{count, plural, =1{1 custom label} other{{count} custom labels}}", + "@formatCustomLabelCount": { + "description": "Moderation labeler detail chip showing custom label count", + "placeholders": { + "count": { + "type": "int", + "example": "3" + } + } + }, + "formatDefinitionCount": "{count, plural, =1{1 definition} other{{count} definitions}}", + "@formatDefinitionCount": { + "description": "Moderation labeler card chip showing custom definition count", + "placeholders": { + "count": { + "type": "int", + "example": "2" + } + } + }, + "formatLoadedRecordsCount": "{count} loaded", + "@formatLoadedRecordsCount": { + "description": "Devtools record list count while total is unknown", + "placeholders": { + "count": { + "type": "int", + "example": "20" + } + } + }, + "formatLoadedRecordsOfTotal": "{loaded} of {total}", + "@formatLoadedRecordsOfTotal": { + "description": "Devtools record list count showing loaded and total records", + "placeholders": { + "loaded": { + "type": "int", + "example": "20" + }, + "total": { + "type": "int", + "example": "100" + } + } + }, + "formatModerationSourceLabel": "{source} label", + "@formatModerationSourceLabel": { + "description": "Tooltip description for a moderation label source", + "placeholders": { + "source": { + "type": "String", + "example": "Bluesky" + } + } + }, + "formatPolicyBlur": "Blur {value}", + "@formatPolicyBlur": { + "description": "Moderation policy chip showing the blur behavior", + "placeholders": { + "value": { + "type": "String", + "example": "content" + } + } + }, + "formatPolicyDefault": "Default {value}", + "@formatPolicyDefault": { + "description": "Moderation policy chip showing default preference", + "placeholders": { + "value": { + "type": "String", + "example": "warn" + } + } + }, + "formatPolicyId": "ID {identifier}", + "@formatPolicyId": { + "description": "Moderation policy chip showing a label identifier", + "placeholders": { + "identifier": { + "type": "String", + "example": "graphic-media" + } + } + }, + "formatPolicySeverity": "Severity {value}", + "@formatPolicySeverity": { + "description": "Moderation policy chip showing severity", + "placeholders": { + "value": { + "type": "String", + "example": "alert" + } + } + }, + "formatPublishedValueCount": "{count, plural, =1{1 published value} other{{count} published values}}", + "@formatPublishedValueCount": { + "description": "Moderation labeler card chip showing published value count", + "placeholders": { + "count": { + "type": "int", + "example": "4" + } + } + }, + "formatRecordsCount": "{count, plural, =1{1 record} other{{count} records}}", + "@formatRecordsCount": { + "description": "Devtools repository record count", + "placeholders": { + "count": { + "type": "int", + "example": "5" + } + } + }, + "formatSubscribedToLabeler": "Subscribed to {name}", + "@formatSubscribedToLabeler": { + "description": "Snackbar after subscribing to a labeler", + "placeholders": { + "name": { + "type": "String", + "example": "Bluesky Safety" + } + } + }, + "labelAdultContentSetting": "Adult content", + "@labelAdultContentSetting": { + "description": "Moderation settings adult content switch label" + }, + "labelAdultOnlyShort": "18+", + "@labelAdultOnlyShort": { + "description": "Short chip label for adult-only moderation labels" + }, + "labelAlwaysOn": "Always on", + "@labelAlwaysOn": { + "description": "Status label for always-on moderation labeler" + }, + "labelAutoScroll": "Auto-scroll", + "@labelAutoScroll": { + "description": "Log viewer auto-scroll control label" + }, + "labelBlueskyModeration": "Bluesky moderation", + "@labelBlueskyModeration": { + "description": "Built-in Bluesky moderation labeler title" + }, + "labelBuiltIn": "Built-in", + "@labelBuiltIn": { + "description": "Chip label for a built-in item" + }, + "labelBuiltInLabeler": "Built-in labeler", + "@labelBuiltInLabeler": { + "description": "Moderation settings section title for built-in labeler" + }, + "labelBuiltInModeration": "Built-in moderation", + "@labelBuiltInModeration": { + "description": "Moderation labeler chip and switch label for built-in moderation" + }, + "labelBlockedAccount": "Blocked account", + "@labelBlockedAccount": { + "description": "Moderation badge for blocked account content" + }, + "labelBlockedByAccount": "Blocked by account", + "@labelBlockedByAccount": { + "description": "Moderation badge for content from an account that blocked the user" + }, + "labelBlockedRelationship": "Blocked relationship", + "@labelBlockedRelationship": { + "description": "Moderation badge for content limited by a block relationship" + }, + "labelCollections": "COLLECTIONS", + "@labelCollections": { + "description": "Devtools section heading for repository collections" + }, + "labelContentPreferenceHide": "Hide", + "@labelContentPreferenceHide": { + "description": "Moderation label preference option to hide matching content" + }, + "labelContentPreferenceIgnore": "Ignore", + "@labelContentPreferenceIgnore": { + "description": "Moderation label preference option to ignore matching content" + }, + "labelContentPreferenceWarn": "Warn", + "@labelContentPreferenceWarn": { + "description": "Moderation label preference option to warn on matching content" + }, + "labelCustomLabelers": "Custom labelers", + "@labelCustomLabelers": { + "description": "Moderation settings section title for custom labelers" + }, + "labelLabeler": "Labeler", + "@labelLabeler": { + "description": "Labeler detail screen app bar title" + }, + "labelLabelerDid": "Labeler DID", + "@labelLabelerDid": { + "description": "Input label for labeler DID" + }, + "labelLabelersAndContentModeration": "Labelers and content moderation", + "@labelLabelersAndContentModeration": { + "description": "Moderation settings hero title" + }, + "labelLabelPreferences": "Label preferences", + "@labelLabelPreferences": { + "description": "Labeler detail section heading for preferences" + }, + "labelHiddenContent": "Hidden content", + "@labelHiddenContent": { + "description": "Moderation badge for hidden content" + }, + "labelLogLevelDebug": "Debug", + "@labelLogLevelDebug": { + "description": "Log level filter chip for debug logs" + }, + "labelLogLevelError": "Error", + "@labelLogLevelError": { + "description": "Log level filter chip for error logs" + }, + "labelLogLevelFatal": "Fatal", + "@labelLogLevelFatal": { + "description": "Log level filter chip for fatal logs" + }, + "labelLogLevelInfo": "Info", + "@labelLogLevelInfo": { + "description": "Log level filter chip for info logs" + }, + "labelLogLevelTrace": "Trace", + "@labelLogLevelTrace": { + "description": "Log level filter chip for trace logs" + }, + "labelLogLevelWarning": "Warning", + "@labelLogLevelWarning": { + "description": "Log level filter chip for warning logs" + }, + "labelModerationNote": "Moderation note", + "@labelModerationNote": { + "description": "Fallback informational moderation badge label" + }, + "labelModerationSourceBluesky": "Bluesky", + "@labelModerationSourceBluesky": { + "description": "Moderation source label for the built-in Bluesky labeler" + }, + "labelModerationSourceSubscribedLabeler": "Subscribed labeler", + "@labelModerationSourceSubscribedLabeler": { + "description": "Moderation source label for a subscribed custom labeler" + }, + "labelMutedAccount": "Muted account", + "@labelMutedAccount": { + "description": "Moderation badge for muted account content" + }, + "labelMutedPhrase": "Muted phrase", + "@labelMutedPhrase": { + "description": "Moderation badge for muted phrase content" + }, + "labelNoCustomLabelDefinitions": "No custom label definitions", + "@labelNoCustomLabelDefinitions": { + "description": "Empty state title when a labeler has no localized custom definitions" + }, + "labelNoCustomLabelers": "No custom labelers", + "@labelNoCustomLabelers": { + "description": "Empty state title when no custom labelers are subscribed" + }, + "labelPdsExplorer": "PDS Explorer", + "@labelPdsExplorer": { + "description": "Developer PDS Explorer screen title" + }, + "labelPublishedPolicies": "Published policies", + "@labelPublishedPolicies": { + "description": "Labeler detail section heading for published policies" + }, + "labelRecordJson": "Record JSON", + "@labelRecordJson": { + "description": "Devtools breadcrumb label for an empty record key" + }, + "labelRefresh": "Refresh", + "@labelRefresh": { + "description": "Generic refresh tooltip label" + }, + "labelRepository": "Repository", + "@labelRepository": { + "description": "Devtools breadcrumb fallback label for repository" + }, + "labelSubscribed": "Subscribed", + "@labelSubscribed": { + "description": "Labeler detail switch label for subscribed labelers" + }, + "labelSensitiveContent": "Sensitive content", + "@labelSensitiveContent": { + "description": "Fallback moderation label for sensitive content" + }, + "labelUnknown": "Unknown", + "@labelUnknown": { + "description": "Generic fallback label when a name is unknown" + }, + "messageAddLabelerDidHelper": "Paste a labeler DID to review and subscribe to its labels.", + "@messageAddLabelerDidHelper": { + "description": "Helper text in add labeler dialog" + }, + "messageAdultContentRequiredForLabels": "Required before 18+ label preferences can be changed.", + "@messageAdultContentRequiredForLabels": { + "description": "Moderation settings adult content switch helper text" + }, + "messageBuiltInLabelerActiveWhenUnavailable": "The built-in labeler is active even if its details cannot be loaded right now.", + "@messageBuiltInLabelerActiveWhenUnavailable": { + "description": "Moderation settings fallback subtitle when built-in labeler details are unavailable" + }, + "messageBuiltInLabelerAlwaysActive": "This labeler is always active.", + "@messageBuiltInLabelerAlwaysActive": { + "description": "Labeler detail subtitle for built-in labeler subscription switch" + }, + "messageDevtoolsEmptyState": "Enter a handle, DID, or AT-URI to explore\na user''s repository.", + "@messageDevtoolsEmptyState": { + "description": "Developer PDS Explorer empty state helper text" + }, + "messageEnableAdultContentForLabel": "Enable adult content to change this 18+ label.", + "@messageEnableAdultContentForLabel": { + "description": "Helper text shown when adult content is required to edit a label preference" + }, + "messageBlockedAccountDescription": "This account is blocked", + "@messageBlockedAccountDescription": { + "description": "Tooltip for blocked account moderation badge" + }, + "messageBlockedByAccountDescription": "This account has blocked you", + "@messageBlockedByAccountDescription": { + "description": "Tooltip for blocked-by account moderation badge" + }, + "messageBlockedRelationshipDescription": "This content is limited by a block relationship", + "@messageBlockedRelationshipDescription": { + "description": "Tooltip for blocked relationship moderation badge" + }, + "messageHiddenContentDescription": "This content is hidden by moderation rules", + "@messageHiddenContentDescription": { + "description": "Tooltip for hidden content moderation badge" + }, + "messageJsonCopiedToClipboard": "JSON copied to clipboard", + "@messageJsonCopiedToClipboard": { + "description": "Snackbar after copying record JSON" + }, + "messageLogsEmpty": "No logs yet", + "@messageLogsEmpty": { + "description": "Log viewer empty state title" + }, + "messageLogsEmptySubtitle": "Log entries will appear here", + "@messageLogsEmptySubtitle": { + "description": "Log viewer empty state subtitle" + }, + "messageModerationSettingsHeroSubtitle": "Manage adult-content visibility, subscribed labelers, and the rules each labeler applies to posts and profiles.", + "@messageModerationSettingsHeroSubtitle": { + "description": "Moderation settings hero subtitle" + }, + "messageModerationGuidanceApplies": "Moderation guidance applies here", + "@messageModerationGuidanceApplies": { + "description": "Fallback moderation badge tooltip" + }, + "messageMutedAccountDescription": "Muted content is being downranked here", + "@messageMutedAccountDescription": { + "description": "Tooltip for muted account moderation badge" + }, + "messageMutedPhraseDescription": "A muted phrase matched this content", + "@messageMutedPhraseDescription": { + "description": "Tooltip for muted phrase moderation badge" + }, + "messageNoCustomLabelDefinitions": "This labeler publishes values, but not localized custom definitions.", + "@messageNoCustomLabelDefinitions": { + "description": "Labeler detail empty state subtitle when no localized custom definitions are available" + }, + "messageNoCustomLabelers": "Add a labeler DID to subscribe and configure its custom labels.", + "@messageNoCustomLabelers": { + "description": "Moderation settings empty state subtitle when no custom labelers are subscribed" + }, + "messageNoLabelDescriptionAvailable": "No description available for this label.", + "@messageNoLabelDescriptionAvailable": { + "description": "Fallback description for a label definition without localized description" + }, + "messageNoLogFileAvailable": "No log file available", + "@messageNoLogFileAvailable": { + "description": "Snackbar when there is no log file to share" + }, + "messageRecordCountsUnavailable": "Record counts unavailable", + "@messageRecordCountsUnavailable": { + "description": "Devtools repository count status when record counts cannot be loaded" + }, + "messageRecordCountsLoading": "Counting records...", + "@messageRecordCountsLoading": { + "description": "Devtools repository count status while record counts are loading" + }, + "messageSubscribedLabelersHeaders": "Subscribed labelers are added to your moderation headers and preferences.", + "@messageSubscribedLabelersHeaders": { + "description": "Labeler detail subtitle for custom labeler subscription switch" + }, + "messageUnableToOpenShareSheet": "Unable to open share sheet. Please try again.", + "@messageUnableToOpenShareSheet": { + "description": "Snackbar when the log share sheet cannot open" + }, + "placeholderHandleDidOrAtUri": "Handle, DID, or at:// URI", + "@placeholderHandleDidOrAtUri": { + "description": "Devtools search input placeholder" + }, + "placeholderLabelerDid": "did:plc:examplelabeler", + "@placeholderLabelerDid": { + "description": "Placeholder DID in add labeler dialog" + }, + "placeholderLogsFilter": "Filter logs...", + "@placeholderLogsFilter": { + "description": "Log viewer search field placeholder" + }, + "subjectLazuriteLogs": "Lazurite logs", + "@subjectLazuriteLogs": { + "description": "Share sheet subject for log files" + }, + "tooltipClearAllLogs": "Clear all logs", + "@tooltipClearAllLogs": { + "description": "Tooltip for clearing all log files" + }, + "tooltipGoToPdsLs": "Go to pds.ls", + "@tooltipGoToPdsLs": { + "description": "Tooltip for opening pds.ls" + }, + "tooltipShareLogFile": "Share log file", + "@tooltipShareLogFile": { + "description": "Tooltip for sharing a log file" } } diff --git a/lib/features/account/presentation/account_switcher_sheet.dart b/lib/features/account/presentation/account_switcher_sheet.dart index b128c14..3c38956 100644 --- a/lib/features/account/presentation/account_switcher_sheet.dart +++ b/lib/features/account/presentation/account_switcher_sheet.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:lazurite/core/database/app_database.dart'; +import 'package:lazurite/core/l10n/l10n.dart'; import 'package:lazurite/features/account/cubit/account_switcher_cubit.dart'; import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; import 'package:lazurite/features/auth/data/atproto_identifier.dart'; @@ -26,6 +27,25 @@ String? validateAtProtoIdentifierInput(String? value) { return code.message; } +String? validateAtProtoIdentifierInputLocalized(BuildContext context, String? value) { + final normalized = normalizeAtProtoIdentifierForAuth(value ?? ''); + final validationError = validateAtProtoIdentifierForAuth(normalized); + if (validationError == null) { + return null; + } + + return _localizedAtProtoIdentifierValidationMessage(context, validationError.code); +} + +String _localizedAtProtoIdentifierValidationMessage(BuildContext context, AtProtoIdentifierValidationErrorCode code) { + return switch (code) { + AtProtoIdentifierValidationErrorCode.empty => context.l10n.validationEnterBlueskyHandleOrDid, + AtProtoIdentifierValidationErrorCode.unsupportedDid => context.l10n.validationUseSupportedDid, + AtProtoIdentifierValidationErrorCode.invalidDid => context.l10n.validationEnterCompleteDid, + AtProtoIdentifierValidationErrorCode.invalidHandle => context.l10n.validationEnterFullHandle, + }; +} + void showAccountSwitcherSheet(BuildContext context) { final cubit = context.read(); final authBloc = context.read(); @@ -67,7 +87,7 @@ class _AccountSwitcherSheet extends StatelessWidget { children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Text('Accounts', style: textTheme.titleMedium), + child: Text(context.l10n.labelAccounts, style: textTheme.titleMedium), ), const Divider(), BlocBuilder( @@ -79,7 +99,7 @@ class _AccountSwitcherSheet extends StatelessWidget { ); } - if (state.accounts.isEmpty) return _buildEmptyState(colorScheme, textTheme); + if (state.accounts.isEmpty) return _buildEmptyState(context, colorScheme, textTheme); return ListView.builder( shrinkWrap: true, @@ -99,7 +119,7 @@ class _AccountSwitcherSheet extends StatelessWidget { children: [ if (isActive) const Icon(Icons.check), IconButton( - tooltip: 'Remove account', + tooltip: context.l10n.labelRemoveAccount, icon: const Icon(Icons.delete_outline), onPressed: () => _onRemoveAccount(context, account.did, account.handle), ), @@ -114,7 +134,7 @@ class _AccountSwitcherSheet extends StatelessWidget { const Divider(), ListTile( leading: const Icon(Icons.person_add_outlined), - title: const Text('Add Account'), + title: Text(context.l10n.buttonAddAccount), onTap: () => _onAddAccount(context), ), ], @@ -122,7 +142,7 @@ class _AccountSwitcherSheet extends StatelessWidget { ); } - Widget _buildEmptyState(ColorScheme colorScheme, TextTheme textTheme) => Padding( + Widget _buildEmptyState(BuildContext context, ColorScheme colorScheme, TextTheme textTheme) => Padding( padding: const EdgeInsets.fromLTRB(24, 20, 24, 24), child: Row( children: [ @@ -130,7 +150,7 @@ class _AccountSwitcherSheet extends StatelessWidget { const SizedBox(width: 12), Expanded( child: Text( - 'No other signed-in accounts yet. Add an account to switch between profiles.', + context.l10n.accountSwitcherNoOtherAccounts, style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), ), ), @@ -148,7 +168,7 @@ class _AccountSwitcherSheet extends StatelessWidget { } if (parentContext.mounted) { - showAppSnackBar(parentContext, 'Please sign in again for that account.'); + showAppSnackBar(parentContext, parentContext.l10n.messagePleaseSignInAgainForAccount); final router = GoRouter.maybeOf(parentContext); if (router != null) { unawaited(Future.delayed(Duration.zero, () => router.go(_reauthLoginLocation(account.handle)))); @@ -170,7 +190,7 @@ class _AccountSwitcherSheet extends StatelessWidget { context: parentContext, builder: (dialogContext) => StatefulBuilder( builder: (dialogContext, setDialogState) => ConfirmationDialog( - title: const Text('Add Account'), + title: Text(parentContext.l10n.buttonAddAccount), content: SizedBox( width: 420, child: Form( @@ -186,9 +206,12 @@ class _AccountSwitcherSheet extends StatelessWidget { minChars: 2, debounceMs: 300, limit: 8, - decoration: const InputDecoration(labelText: 'Handle or DID', hintText: 'username.bsky.social'), + decoration: InputDecoration( + labelText: parentContext.l10n.promptHandleOrDid, + hintText: parentContext.l10n.placeholderUsernameBskySocial, + ), textInputAction: TextInputAction.done, - validator: validateAtProtoIdentifierInput, + validator: (value) => validateAtProtoIdentifierInputLocalized(parentContext, value), onChanged: (_) => setDialogState(() {}), onFieldSubmitted: (_) { if ((formKey.currentState?.validate() ?? false)) { @@ -199,7 +222,7 @@ class _AccountSwitcherSheet extends StatelessWidget { ), ), confirmEnabled: _isIdentifierInputValid(controller.text.trim()), - confirmLabel: 'Continue', + confirmLabel: parentContext.l10n.buttonContinue, onCancel: () => Navigator.pop(dialogContext), onConfirm: () { if (!(formKey.currentState?.validate() ?? false)) { @@ -219,7 +242,11 @@ class _AccountSwitcherSheet extends StatelessWidget { if (tokens != null) { authBloc.add(SessionRestored(tokens: tokens)); } else if (parentContext.mounted) { - showAppSnackBar(parentContext, cubit.lastAddAccountErrorMessage ?? 'Failed to add account', isError: true); + showAppSnackBar( + parentContext, + cubit.lastAddAccountErrorMessage ?? parentContext.l10n.errorFailedToAddAccount, + isError: true, + ); } } @@ -228,9 +255,9 @@ class _AccountSwitcherSheet extends StatelessWidget { final remove = await showDialog( context: parentContext, builder: (dialogContext) => ConfirmationDialog( - title: const Text('Remove Account'), - content: Text('Remove @$handle from this device?'), - confirmLabel: 'Remove', + title: Text(parentContext.l10n.dialogRemoveAccountTitle), + content: Text(parentContext.l10n.formatRemoveAccountContent(handle)), + confirmLabel: parentContext.l10n.buttonRemove, onCancel: () => Navigator.pop(dialogContext, false), onConfirm: () => Navigator.pop(dialogContext, true), ), @@ -243,7 +270,7 @@ class _AccountSwitcherSheet extends StatelessWidget { final result = await cubit.removeAccount(did); if (!result.removed) { if (parentContext.mounted) { - showAppSnackBar(parentContext, 'Unable to remove account right now.', isError: true); + showAppSnackBar(parentContext, parentContext.l10n.errorUnableToRemoveAccountNow, isError: true); } return; } diff --git a/lib/features/alerts/presentation/alerts_screen.dart b/lib/features/alerts/presentation/alerts_screen.dart index 6d81cfe..0dd4bcf 100644 --- a/lib/features/alerts/presentation/alerts_screen.dart +++ b/lib/features/alerts/presentation/alerts_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:bluesky/chat_bsky_convo_defs.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import 'package:lazurite/core/l10n/l10n.dart'; import 'package:lazurite/core/widgets/lazurite_app_bar.dart'; import 'package:lazurite/features/messages/bloc/convo_list_bloc.dart'; import 'package:lazurite/features/messages/presentation/widgets/convo_list_pane.dart'; @@ -40,9 +41,9 @@ class _AlertsScreenState extends State { return AppScreenEntrance( child: Scaffold( appBar: LazuriteAppBar( - sectionLabel: 'Alerts', + sectionLabel: context.l10n.labelAlertsTitle, actions: currentTab == AlertsTab.notifications - ? [TextButton(onPressed: () => _markAllRead(context), child: const Text('Mark All Read'))] + ? [TextButton(onPressed: () => _markAllRead(context), child: Text(context.l10n.buttonMarkAllRead))] : null, bottom: PreferredSize( preferredSize: const Size.fromHeight(48), @@ -108,17 +109,17 @@ class _AlertsTabs extends StatelessWidget { children: [ _AlertsTabButton( tab: AlertsTab.notifications, - label: 'Notifications', + label: context.l10n.labelNotifications, currentTab: currentTab, unreadCount: notificationsUnreadCount, ), _AlertsTabButton( tab: AlertsTab.messages, - label: 'Messages', + label: context.l10n.labelMessages, currentTab: currentTab, unreadCount: messagesUnreadCount, ), - _AlertsTabButton(tab: AlertsTab.requests, label: 'Requests', currentTab: currentTab), + _AlertsTabButton(tab: AlertsTab.requests, label: context.l10n.labelMessageRequests, currentTab: currentTab), ], ), ); diff --git a/lib/features/compose/presentation/compose_screen.dart b/lib/features/compose/presentation/compose_screen.dart index b9f6a7e..f617c5c 100644 --- a/lib/features/compose/presentation/compose_screen.dart +++ b/lib/features/compose/presentation/compose_screen.dart @@ -10,12 +10,12 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:image_picker/image_picker.dart'; import 'package:intl/intl.dart'; import 'package:lazurite/core/database/app_database.dart'; +import 'package:lazurite/core/l10n/l10n.dart'; import 'package:lazurite/core/logging/app_logger.dart'; import 'package:lazurite/core/theme/theme_extensions.dart'; import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; import 'package:lazurite/features/compose/bloc/compose_bloc.dart'; import 'package:lazurite/features/compose/data/link_preview_service.dart'; -import 'package:lazurite/features/connectivity/connectivity_helpers.dart'; import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; import 'package:lazurite/features/profile/data/profile_repository.dart'; import 'package:lazurite/features/typeahead/data/typeahead_repository.dart'; @@ -24,6 +24,7 @@ import 'package:lazurite/shared/presentation/helpers/snackbar_helper.dart'; import 'package:lazurite/shared/presentation/widgets/confirmation_dialog.dart'; import 'package:lazurite/shared/presentation/widgets/external_link_preview_card.dart'; import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; +import 'package:lazurite/shared/utils/format_utils.dart'; import 'package:video_player/video_player.dart'; class ComposeScreen extends StatefulWidget { @@ -407,7 +408,7 @@ class _ComposeScreenState extends State { final state = context.read().state; if (!state.canAddMoreMedia) { if (mounted) { - showAppSnackBar(context, 'Maximum 4 images allowed', isError: true); + showAppSnackBar(context, context.l10n.messageComposeImageMaxCount, isError: true); } return; } @@ -426,7 +427,7 @@ class _ComposeScreenState extends State { const maxSize = 1 * 1024 * 1024; if (fileSize > maxSize) { if (mounted) { - showAppSnackBar(context, 'Image must be smaller than 1MB', isError: true); + showAppSnackBar(context, context.l10n.messageComposeImageMustBeUnder1Mb, isError: true); } return; } @@ -435,7 +436,7 @@ class _ComposeScreenState extends State { const validExtensions = ['jpg', 'jpeg', 'png', 'webp']; if (!validExtensions.contains(extension)) { if (mounted) { - showAppSnackBar(context, 'Image must be JPEG, PNG, or WebP', isError: true); + showAppSnackBar(context, context.l10n.messageComposeImageMustBeJpegPngWebp, isError: true); } return; } @@ -452,7 +453,7 @@ class _ComposeScreenState extends State { } } catch (e) { if (mounted) { - showAppSnackBar(context, 'Failed to pick image: $e', isError: true); + showAppSnackBar(context, context.l10n.formatComposeFailedToPickImage(e), isError: true); } } } @@ -461,7 +462,7 @@ class _ComposeScreenState extends State { final state = context.read().state; if (!state.canAddVideo) { if (mounted) { - showAppSnackBar(context, 'Remove existing media before adding a video', isError: true); + showAppSnackBar(context, context.l10n.messageComposeRemoveExistingMediaBeforeVideo, isError: true); } return; } @@ -473,7 +474,7 @@ class _ComposeScreenState extends State { } } catch (e) { if (mounted) { - showAppSnackBar(context, 'Failed to pick video: $e', isError: true); + showAppSnackBar(context, context.l10n.formatComposeFailedToPickVideo(e), isError: true); } } } @@ -544,35 +545,35 @@ class _ComposeScreenState extends State { } } - String _formatDraftTime(DateTime dateTime) { - final now = DateTime.now(); - final difference = now.difference(dateTime); - - if (difference.isNegative) { - return 'Just now'; - } - - if (difference.inMinutes < 1) { - return 'Just now'; - } else if (difference.inMinutes < 60) { - return '${difference.inMinutes}m ago'; - } else if (difference.inHours < 24) { - return '${difference.inHours}h ago'; - } else if (difference.inDays < 7) { - return '${difference.inDays}d ago'; - } else { - return DateFormat('MMM d').format(dateTime); - } + String _formatDraftTime(BuildContext context, DateTime dateTime) { + return formatRelativeTime( + dateTime, + nowLabel: context.l10n.commonJustNow, + includeAgo: true, + locale: Localizations.localeOf(context).toString(), + ); } - String _videoStatusLabel(VideoAttachment video) { + String _videoStatusLabel(BuildContext context, VideoAttachment video) { return switch (video.status) { - VideoUploadStatus.idle => 'Ready to upload', - VideoUploadStatus.checkingLimits => 'Checking upload limits…', - VideoUploadStatus.uploading => video.uploadProgress > 0 ? 'Uploading… ${video.uploadProgress}%' : 'Uploading…', - VideoUploadStatus.processing => video.uploadProgress > 0 ? 'Processing… ${video.uploadProgress}%' : 'Processing…', - VideoUploadStatus.ready => video.altText.isNotEmpty ? 'Ready · "${video.altText}"' : 'Ready', - VideoUploadStatus.error => video.errorMessage ?? 'Upload failed', + VideoUploadStatus.idle => context.l10n.messageVideoReadyToUpload, + VideoUploadStatus.checkingLimits => context.l10n.messageVideoCheckingUploadLimits, + VideoUploadStatus.uploading => + video.uploadProgress > 0 + ? '${context.l10n.messageVideoUploading} ${video.uploadProgress}%' + : context.l10n.messageVideoUploading, + VideoUploadStatus.processing => + video.uploadProgress > 0 + ? '${context.l10n.messageVideoProcessing} ${video.uploadProgress}%' + : context.l10n.messageVideoProcessing, + VideoUploadStatus.ready => + video.altText.isNotEmpty + ? context.l10n.formatComposeVideoReadyWithAltText(video.altText) + : context.l10n.messageVideoReady, + VideoUploadStatus.error => _localizedComposeError( + context, + video.errorMessage ?? context.l10n.messageVideoUploadFailed, + ), }; } @@ -607,9 +608,12 @@ class _ComposeScreenState extends State { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text('Drafts', style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)), Text( - '${state.drafts.length} draft${state.drafts.length == 1 ? '' : 's'}', + context.l10n.messageComposeDrafts, + style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), + ), + Text( + context.l10n.formatDraftCount(state.drafts.length), style: theme.textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), ), ], @@ -625,7 +629,7 @@ class _ComposeScreenState extends State { padding: const EdgeInsets.symmetric(vertical: 26), child: Center( child: Text( - 'No drafts saved', + context.l10n.messageComposeNoDraftsSaved, style: theme.textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), ), ), @@ -640,7 +644,7 @@ class _ComposeScreenState extends State { final draft = state.drafts[index]; return _DraftListItem( draft: draft, - formattedTime: _formatDraftTime(draft.updatedAt), + formattedTime: _formatDraftTime(context, draft.updatedAt), onTap: () { setState(() => _showDrafts = false); context.read().add(DraftLoaded(draft.id)); @@ -649,9 +653,9 @@ class _ComposeScreenState extends State { final bloc = context.read(); showConfirmationDialog( context: context, - title: const Text('Delete Draft?'), - content: const Text('This action cannot be undone.'), - confirmLabel: 'Delete', + title: Text(context.l10n.dialogDeleteDraftTitle), + content: Text(context.l10n.dialogDeletePostContent), + confirmLabel: context.l10n.buttonDelete, confirmDestructive: true, ).then((confirmed) { if (confirmed && mounted) { @@ -748,7 +752,9 @@ class _ComposeScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - quotedHandle != null && quotedHandle.isNotEmpty ? 'Quoting @$quotedHandle' : 'Quoting post', + quotedHandle != null && quotedHandle.isNotEmpty + ? context.l10n.formatComposeQuotingHandle(quotedHandle) + : context.l10n.messageComposeQuotingPost, style: theme.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w600), ), if (quotedText.isNotEmpty) ...[ @@ -767,7 +773,7 @@ class _ComposeScreenState extends State { onPressed: () => context.read().add(const QuoteContextCleared()), icon: const Icon(Icons.close), visualDensity: VisualDensity.compact, - tooltip: 'Remove quoted post', + tooltip: context.l10n.messageComposeRemoveQuotedPost, ), ], ), @@ -807,19 +813,16 @@ class _ComposeScreenState extends State { if (context.read().state.isEditing) return; context.read().add(const DraftSaved()); if (mounted) { - showAppSnackBar(context, 'Draft saved'); + showAppSnackBar(context, context.l10n.messageComposeDraftSaved); } } Future _showEditAlgorithmInfo() async { await showConfirmationDialog( context: context, - title: const Text('How Post Editing Works'), - content: const Text( - 'Lazurite saves edits by deleting and recreating the post record with the same URI. During re-indexing, ' - 'ranking, counters, and search visibility can shift, and updates may take time to appear everywhere.', - ), - confirmLabel: 'OK', + title: Text(context.l10n.dialogEditAlgorithmTitle), + content: Text(context.l10n.dialogEditAlgorithmContent), + confirmLabel: context.l10n.buttonOk, showCancel: false, ); } @@ -834,9 +837,9 @@ class _ComposeScreenState extends State { if (state.isDraftDirty) { showConfirmationDialog( context: context, - title: const Text('Discard Changes?'), - content: const Text('You have unsaved edits. Discard them and leave?'), - confirmLabel: 'Discard', + title: Text(context.l10n.dialogDiscardChangesTitle), + content: Text(context.l10n.dialogDiscardChangesContent), + confirmLabel: context.l10n.buttonDiscard, ).then((shouldDiscard) { if (shouldDiscard && mounted) { navigator.pop(false); @@ -851,10 +854,10 @@ class _ComposeScreenState extends State { if (hasContent && state.isDraftDirty) { showConfirmationDialog( context: context, - title: const Text('Save Draft?'), - content: const Text('You have unsaved content. Would you like to save it as a draft?'), - cancelLabel: 'Discard', - confirmLabel: 'Save', + title: Text(context.l10n.dialogSaveDraftTitle), + content: Text(context.l10n.dialogSaveDraftContent), + cancelLabel: context.l10n.buttonDiscard, + confirmLabel: context.l10n.buttonSave, ).then((shouldSave) { if (shouldSave) { _saveDraft(); @@ -887,7 +890,7 @@ class _ComposeScreenState extends State { } } - return 'Lazurite'; + return context.l10n.appTitle; } Widget _buildComposerAvatar() { @@ -926,37 +929,35 @@ class _ComposeScreenState extends State { ); } - Widget _buildComposerTextArea() { - return Expanded( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildComposerAvatar(), - const SizedBox(width: 12), - Expanded( - child: TextField( - controller: _textController, - focusNode: _textFocusNode, - autofocus: true, - maxLines: null, - expands: true, - textAlignVertical: TextAlignVertical.top, - decoration: InputDecoration( - hintText: "What's on your mind?", - hintStyle: theme.textTheme.bodyLarge?.copyWith(color: theme.colorScheme.onSurfaceVariant), - border: InputBorder.none, - contentPadding: EdgeInsets.zero, - ), - style: theme.textTheme.bodyLarge?.copyWith(height: 1.5, fontSize: 16), + Widget _buildComposerTextArea() => Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildComposerAvatar(), + const SizedBox(width: 12), + Expanded( + child: TextField( + controller: _textController, + focusNode: _textFocusNode, + autofocus: true, + maxLines: null, + expands: true, + textAlignVertical: TextAlignVertical.top, + decoration: InputDecoration( + hintText: context.l10n.messageComposePlaceholder, + hintStyle: theme.textTheme.bodyLarge?.copyWith(color: theme.colorScheme.onSurfaceVariant), + border: InputBorder.none, + contentPadding: EdgeInsets.zero, ), + style: theme.textTheme.bodyLarge?.copyWith(height: 1.5, fontSize: 16), ), - ], - ), + ), + ], ), - ); - } + ), + ); Widget _buildScheduledPill(ComposeState state) { if (!state.hasScheduledTime) { @@ -980,14 +981,16 @@ class _ComposeScreenState extends State { Icon(Icons.schedule, size: 15, color: colorScheme.onPrimary), const SizedBox(width: 7), Text( - 'Scheduled for ${DateFormat('MMM d, h:mm a').format(state.scheduledAt!)}', + context.l10n.formatComposeScheduledFor( + DateFormat.yMMMd(Localizations.localeOf(context).toString()).add_jm().format(state.scheduledAt!), + ), style: theme.textTheme.bodySmall?.copyWith(color: colorScheme.onPrimary, fontWeight: FontWeight.w600), ), const SizedBox(width: 2), IconButton( onPressed: () => context.read().add(const ScheduleCleared()), icon: Icon(Icons.close, size: 16, color: colorScheme.onPrimary), - tooltip: 'Clear scheduled time', + tooltip: context.l10n.messageComposeClearScheduledTime, constraints: const BoxConstraints(minWidth: 40, minHeight: 40), padding: EdgeInsets.zero, visualDensity: VisualDensity.compact, @@ -1038,7 +1041,7 @@ class _ComposeScreenState extends State { if (state.isSuccess) { if (state.isEditing) { - showAppSnackBar(context, 'Changes saved.', behavior: SnackBarBehavior.floating); + showAppSnackBar(context, context.l10n.messageChangesSaved, behavior: SnackBarBehavior.floating); } Navigator.of(context).pop( state.isEditing @@ -1053,7 +1056,12 @@ class _ComposeScreenState extends State { } if (state.hasError && state.errorMessage != null) { - showAppSnackBar(context, state.errorMessage!, behavior: SnackBarBehavior.floating, isError: true); + showAppSnackBar( + context, + _localizedComposeError(context, state.errorMessage!), + behavior: SnackBarBehavior.floating, + isError: true, + ); } }, child: PopScope( @@ -1069,11 +1077,14 @@ class _ComposeScreenState extends State { backgroundColor: theme.colorScheme.surface, surfaceTintColor: Colors.transparent, shape: Border(bottom: BorderSide(color: theme.colorScheme.outlineVariant)), - leading: TextButton(onPressed: () => _handleBackNavigation(context), child: const Text('Cancel')), + leading: TextButton( + onPressed: () => _handleBackNavigation(context), + child: Text(context.l10n.buttonCancel), + ), leadingWidth: 80, title: BlocBuilder( builder: (context, state) => Text( - state.isEditing ? 'Edit Post' : 'New Post', + state.isEditing ? context.l10n.labelEditPost : context.l10n.labelNewPost, style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), ), ), @@ -1091,13 +1102,16 @@ class _ComposeScreenState extends State { ), child: state.isSubmitting ? const SizedBox(width: 18, height: 18, child: CircularProgressIndicator(strokeWidth: 2)) - : Text(state.isEditing ? 'Save Changes' : 'Post'), + : Text(state.isEditing ? context.l10n.buttonSaveChanges : context.l10n.buttonPost), ); return Padding( padding: const EdgeInsets.only(right: 8), child: isOffline - ? Tooltip(message: offlineActionMessage('publish your post'), child: button) + ? Tooltip( + message: context.l10n.formatOfflineReconnectAction(context.l10n.actionPublishYourPost), + child: button, + ) : button, ); }, @@ -1124,15 +1138,14 @@ class _ComposeScreenState extends State { const SizedBox(width: 12), Expanded( child: Text( - 'Edits are saved by replacing the record while keeping this post URI. Ranking, ' - 'counts, and visibility may shift while networks re-index.', + context.l10n.messageComposeEditNotice, style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), ), ), IconButton( onPressed: _showEditAlgorithmInfo, icon: const Icon(Icons.help_outline), - tooltip: 'More info', + tooltip: context.l10n.labelMoreInfo, ), ], ), @@ -1155,7 +1168,7 @@ class _ComposeScreenState extends State { Icon(Icons.reply, size: 16, color: theme.colorScheme.onSurfaceVariant), const SizedBox(width: 8), Text( - 'Replying to ', + '${context.l10n.messageReplyingTo} ', style: Theme.of( context, ).textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), @@ -1237,7 +1250,7 @@ class _ComposeScreenState extends State { constraints: const BoxConstraints(minWidth: 40, minHeight: 30), child: Center( child: Text( - 'ALT', + context.l10n.labelAlt, style: theme.textTheme.labelSmall?.copyWith( color: attachment.altText.isNotEmpty ? theme.colorScheme.onPrimary @@ -1261,7 +1274,7 @@ class _ComposeScreenState extends State { style: IconButton.styleFrom(backgroundColor: Colors.black54), onPressed: () => context.read().add(MediaRemoved(index)), icon: const Icon(Icons.close, size: 16, color: Colors.white), - tooltip: 'Remove image', + tooltip: context.l10n.messageComposeRemoveImage, ), ), ), @@ -1327,7 +1340,7 @@ class _ComposeScreenState extends State { ), const SizedBox(height: 2), Text( - _videoStatusLabel(video), + _videoStatusLabel(context, video), style: theme.textTheme.bodySmall?.copyWith( color: video.hasError ? theme.colorScheme.error @@ -1349,7 +1362,7 @@ class _ComposeScreenState extends State { if (video.isReady) ...[ IconButton( icon: const Icon(Icons.subtitles_outlined), - tooltip: 'Add alt text', + tooltip: context.l10n.messageComposeAddAltText, onPressed: () => _showVideoAltTextDialog(video), color: video.altText.isNotEmpty ? theme.colorScheme.primary @@ -1360,6 +1373,7 @@ class _ComposeScreenState extends State { icon: const Icon(Icons.close), onPressed: () => context.read().add(const VideoRemoved()), color: theme.colorScheme.onSurfaceVariant, + tooltip: context.l10n.buttonRemove, ), ], ), @@ -1388,7 +1402,7 @@ class _ComposeScreenState extends State { if (state.isEditing) return const SizedBox.shrink(); return _buildToolbarIconButton( icon: Icons.image_outlined, - tooltip: 'Add image', + tooltip: context.l10n.messageComposeAddImage, onPressed: state.canAddMoreMedia ? _pickImage : null, ); }, @@ -1398,7 +1412,7 @@ class _ComposeScreenState extends State { if (state.isEditing) return const SizedBox.shrink(); return _buildToolbarIconButton( icon: Icons.videocam_outlined, - tooltip: 'Add video', + tooltip: context.l10n.messageComposeAddVideo, onPressed: state.canAddVideo ? _pickVideo : null, ); }, @@ -1413,7 +1427,7 @@ class _ComposeScreenState extends State { state.hasScheduledTime; return _buildToolbarIconButton( icon: Icons.save_outlined, - tooltip: 'Save draft', + tooltip: context.l10n.messageComposeSaveDraft, onPressed: hasDraftableContent ? _saveDraft : null, ); }, @@ -1423,7 +1437,7 @@ class _ComposeScreenState extends State { if (state.isEditing) return const SizedBox.shrink(); return _buildToolbarIconButton( icon: Icons.drive_file_rename_outline, - tooltip: 'Drafts', + tooltip: context.l10n.messageComposeDrafts, onPressed: _toggleDrafts, isActive: _showDrafts, ); @@ -1434,7 +1448,7 @@ class _ComposeScreenState extends State { if (state.isEditing) return const SizedBox.shrink(); return _buildToolbarIconButton( icon: Icons.schedule, - tooltip: 'Schedule', + tooltip: context.l10n.labelSchedule, onPressed: _showSchedulePicker, isActive: state.hasScheduledTime, ); @@ -1457,6 +1471,55 @@ class _ComposeScreenState extends State { ), ); } + + String _localizedComposeError(BuildContext context, String message) { + final imageTooLarge = RegExp(r'^Image "(.+)" is ([0-9.]+) MB .+ max 1 MB\.$').firstMatch(message); + if (imageTooLarge != null) { + return context.l10n.formatComposeImageTooLarge(imageTooLarge.group(1)!, imageTooLarge.group(2)!); + } + + final videoTooLarge = RegExp(r'^Video is ([0-9.]+) MB .+ exceeds the 100 MB limit\.$').firstMatch(message); + if (videoTooLarge != null) { + return context.l10n.formatComposeVideoTooLarge(videoTooLarge.group(1)!); + } + + if (message.startsWith('Failed to pick image: ')) { + return context.l10n.formatComposeFailedToPickImage(message.substring('Failed to pick image: '.length)); + } + if (message.startsWith('Failed to pick video: ')) { + return context.l10n.formatComposeFailedToPickVideo(message.substring('Failed to pick video: '.length)); + } + if (message.startsWith('Failed to save changes: ')) { + return context.l10n.formatComposeFailedToSaveChanges(message.substring('Failed to save changes: '.length)); + } + if (message.startsWith('Failed to submit post: ')) { + return context.l10n.formatComposeFailedToSubmitPost(message.substring('Failed to submit post: '.length)); + } + if (message.startsWith('Upload failed: ')) { + return context.l10n.messageVideoUploadFailed; + } + + return switch (message) { + 'Daily video upload limit reached.' => context.l10n.messageVideoDailyUploadLimitReached, + 'Upload failed — please try again.' => context.l10n.messageVideoUploadFailed, + 'Video processing failed.' => context.l10n.messageVideoProcessingFailed, + 'Video processing timed out.' => context.l10n.messageVideoProcessingTimedOut, + 'Edit context is missing. Please reopen the editor and try again.' => context.l10n.errorComposeEditContextMissing, + 'Failed to save changes. Please try again.' => context.l10n.errorComposeFailedToSaveChanges, + 'Image file not found. Please re-attach and try again.' => context.l10n.errorComposeImageFileNotFound, + 'Unsupported image format. Use JPEG, PNG, or WebP.' => context.l10n.errorComposeUnsupportedImageFormat, + 'Failed to upload image. Please try again.' => context.l10n.errorComposeFailedToUploadImage, + 'Failed to create post. Please try again.' => context.l10n.errorComposeFailedToCreatePost, + 'Network error — post saved as draft.' => context.l10n.errorComposeNetworkSavedAsDraft, + 'This post was changed elsewhere. Reopen it and try editing again.' => context.l10n.errorComposeChangedElsewhere, + 'Could not save changes. Your original post was restored.' => context.l10n.errorComposeOriginalPostRestored, + 'Could not save changes and we could not confirm recovery. Reopen the thread and verify the post.' => + context.l10n.errorComposeCouldNotSaveAndConfirmRecovery, + 'Edit was submitted but could not be confirmed yet. Please reopen the post and verify.' => + context.l10n.errorComposeCouldNotConfirmEdit, + _ => message, + }; + } } class _DraftListItem extends StatelessWidget { @@ -1484,7 +1547,7 @@ class _DraftListItem extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - draft.content.isEmpty ? '(No text)' : draft.content, + draft.content.isEmpty ? context.l10n.messageComposeNoText : draft.content, maxLines: 2, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodyMedium?.copyWith(height: 1.35), @@ -1502,7 +1565,7 @@ class _DraftListItem extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration(color: colorScheme.primary, borderRadius: BorderRadius.circular(4)), child: Text( - 'Scheduled', + context.l10n.labelScheduled, style: theme.textTheme.labelSmall?.copyWith( color: colorScheme.onPrimary, fontWeight: FontWeight.w700, @@ -1518,7 +1581,7 @@ class _DraftListItem extends StatelessWidget { IconButton( icon: Icon(Icons.delete_outline, color: colorScheme.error), onPressed: onDelete, - tooltip: 'Delete draft', + tooltip: context.l10n.labelDeleteDraft, visualDensity: VisualDensity.compact, ), ], @@ -1636,8 +1699,14 @@ class _ImageAltTextDialogState extends State<_ImageAltTextDialog> { children: [ Row( children: [ - Expanded(child: Text('Alt text', style: theme.textTheme.titleLarge)), - IconButton(tooltip: 'Close', onPressed: widget.onCancel, icon: const Icon(Icons.close)), + Expanded( + child: Text(context.l10n.messageComposeImageAltTextTitle, style: theme.textTheme.titleLarge), + ), + IconButton( + tooltip: context.l10n.labelClose, + onPressed: widget.onCancel, + icon: const Icon(Icons.close), + ), ], ), const SizedBox(height: 12), @@ -1665,15 +1734,18 @@ class _ImageAltTextDialogState extends State<_ImageAltTextDialog> { maxLines: 5, maxLength: 1000, textInputAction: TextInputAction.newline, - decoration: const InputDecoration(hintText: 'Describe the image', border: OutlineInputBorder()), + decoration: InputDecoration( + hintText: context.l10n.messageComposeDescribeImage, + border: const OutlineInputBorder(), + ), ), const SizedBox(height: 8), Row( mainAxisAlignment: MainAxisAlignment.end, children: [ - TextButton(onPressed: widget.onCancel, child: const Text('Cancel')), + TextButton(onPressed: widget.onCancel, child: Text(context.l10n.buttonCancel)), const SizedBox(width: 8), - FilledButton(onPressed: () => widget.onSave(_controller.text), child: const Text('Save')), + FilledButton(onPressed: () => widget.onSave(_controller.text), child: Text(context.l10n.buttonSave)), ], ), ], @@ -1723,8 +1795,14 @@ class _VideoAltTextDialogState extends State<_VideoAltTextDialog> { children: [ Row( children: [ - Expanded(child: Text('Video alt text', style: theme.textTheme.titleLarge)), - IconButton(tooltip: 'Close', onPressed: widget.onCancel, icon: const Icon(Icons.close)), + Expanded( + child: Text(context.l10n.messageComposeVideoAltTextTitle, style: theme.textTheme.titleLarge), + ), + IconButton( + tooltip: context.l10n.labelClose, + onPressed: widget.onCancel, + icon: const Icon(Icons.close), + ), ], ), const SizedBox(height: 12), @@ -1737,15 +1815,18 @@ class _VideoAltTextDialogState extends State<_VideoAltTextDialog> { maxLines: 5, maxLength: 1000, textInputAction: TextInputAction.newline, - decoration: const InputDecoration(hintText: 'Describe the video', border: OutlineInputBorder()), + decoration: InputDecoration( + hintText: context.l10n.messageComposeDescribeVideo, + border: const OutlineInputBorder(), + ), ), const SizedBox(height: 8), Row( mainAxisAlignment: MainAxisAlignment.end, children: [ - TextButton(onPressed: widget.onCancel, child: const Text('Cancel')), + TextButton(onPressed: widget.onCancel, child: Text(context.l10n.buttonCancel)), const SizedBox(width: 8), - FilledButton(onPressed: () => widget.onSave(_controller.text), child: const Text('Save')), + FilledButton(onPressed: () => widget.onSave(_controller.text), child: Text(context.l10n.buttonSave)), ], ), ], @@ -1911,7 +1992,7 @@ class _VideoPreviewFallback extends StatelessWidget { Icon(Icons.videocam_outlined, size: 40, color: colorScheme.onSurfaceVariant), const SizedBox(height: 10), Text( - filename.isEmpty ? 'Video' : filename, + filename.isEmpty ? context.l10n.labelVideo : filename, key: const ValueKey('video-alt-preview-filename'), style: theme.textTheme.bodyMedium, maxLines: 1, @@ -1921,7 +2002,7 @@ class _VideoPreviewFallback extends StatelessWidget { if (error != null) ...[ const SizedBox(height: 4), Text( - 'Preview unavailable', + context.l10n.messageComposePreviewUnavailable, style: theme.textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), textAlign: TextAlign.center, ), diff --git a/lib/features/devtools/presentation/dev_tools_screen.dart b/lib/features/devtools/presentation/dev_tools_screen.dart index 677ad31..4c44897 100644 --- a/lib/features/devtools/presentation/dev_tools_screen.dart +++ b/lib/features/devtools/presentation/dev_tools_screen.dart @@ -6,6 +6,7 @@ import 'package:bluesky/app_bsky_actor_defs.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lazurite/core/l10n/l10n.dart'; import 'package:lazurite/core/theme/typography.dart'; import 'package:lazurite/core/theme/theme_extensions.dart'; import 'package:lazurite/core/widgets/app_breadcrumbs.dart'; @@ -19,13 +20,15 @@ class DevToolsScreen extends StatelessWidget { @override Widget build(BuildContext context) { + final l10n = context.l10n; + return Scaffold( appBar: AppBar( - title: const Text('PDS Explorer'), + title: Text(l10n.labelPdsExplorer), actions: [ IconButton( icon: const Icon(Icons.open_in_new), - tooltip: 'Go to pds.ls', + tooltip: l10n.tooltipGoToPdsLs, onPressed: () => _openExternalUrl('https://pds.ls'), ), ], @@ -100,6 +103,7 @@ class _SearchInputState extends State<_SearchInput> { @override Widget build(BuildContext context) { + final l10n = context.l10n; final shouldShowTypeahead = _controller.text.trim().startsWith('@') && (widget.state.isTypeaheadLoading || widget.state.typeaheadActors.isNotEmpty); @@ -114,10 +118,10 @@ class _SearchInputState extends State<_SearchInput> { Expanded( child: TextField( controller: _controller, - decoration: const InputDecoration( - hintText: 'Handle, DID, or at:// URI', - border: OutlineInputBorder(), - contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 12), + decoration: InputDecoration( + hintText: l10n.placeholderHandleDidOrAtUri, + border: const OutlineInputBorder(), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), isDense: true, ), style: AppTypography.googleSansCode(fontSize: 13), @@ -128,7 +132,7 @@ class _SearchInputState extends State<_SearchInput> { const SizedBox(width: 8), FilledButton( onPressed: widget.state.isLoading ? null : () => _resolve(_controller.text), - child: const Text('Resolve'), + child: Text(l10n.buttonResolve), ), ], ), @@ -243,7 +247,8 @@ class _BreadcrumbBar extends StatelessWidget { List _items(BuildContext context) { final cubit = context.read(); - final repoLabel = state.repoHandle ?? state.handle ?? state.did ?? 'Repository'; + final l10n = context.l10n; + final repoLabel = state.repoHandle ?? state.handle ?? state.did ?? l10n.labelRepository; final items = [ AppBreadcrumbItem( label: repoLabel, @@ -266,7 +271,7 @@ class _BreadcrumbBar extends StatelessWidget { if (state.selectedRecord != null) { items.add( AppBreadcrumbItem( - label: state.selectedRecord!.rkey.isEmpty ? 'Record JSON' : state.selectedRecord!.rkey, + label: state.selectedRecord!.rkey.isEmpty ? l10n.labelRecordJson : state.selectedRecord!.rkey, tooltip: state.selectedRecord!.uri, key: const ValueKey('dev-tools-breadcrumb-record'), ), @@ -297,10 +302,10 @@ class _Content extends StatelessWidget { children: [ Icon(Icons.error_outline, size: 48, color: context.colorScheme.error), const SizedBox(height: 16), - Text('Error', style: context.textTheme.titleMedium), + Text(context.l10n.labelLogLevelError, style: context.textTheme.titleMedium), const SizedBox(height: 8), Text( - state.errorMessage ?? 'Unknown error', + state.errorMessage ?? context.l10n.errorUnknown, textAlign: TextAlign.center, style: context.textTheme.bodyMedium, ), @@ -343,11 +348,10 @@ class _EmptyState extends StatelessWidget { children: [ Icon(Icons.explore_outlined, size: 64, color: context.colorScheme.outline), const SizedBox(height: 16), - Text('PDS Explorer', style: context.textTheme.titleMedium), + Text(context.l10n.labelPdsExplorer, style: context.textTheme.titleMedium), const SizedBox(height: 8), Text( - 'Enter a handle, DID, or AT-URI to explore\n' - 'a user\'s repository.', + context.l10n.messageDevtoolsEmptyState, textAlign: TextAlign.center, style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.outline), ), @@ -355,7 +359,7 @@ class _EmptyState extends StatelessWidget { TextButton.icon( onPressed: () => _openExternalUrl('https://pds.ls'), icon: const Icon(Icons.open_in_new, size: 16), - label: const Text('Inspired by pds.ls'), + label: Text(context.l10n.buttonInspiredByPdsLs), ), ], ), @@ -395,7 +399,10 @@ class _RepoOverview extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(state.repoHandle ?? state.handle ?? 'Unknown', style: context.textTheme.titleMedium), + Text( + state.repoHandle ?? state.handle ?? context.l10n.labelUnknown, + style: context.textTheme.titleMedium, + ), const SizedBox(height: 2), Text( state.did ?? '', @@ -413,13 +420,15 @@ class _RepoOverview extends StatelessWidget { runSpacing: 4, children: [ Text( - '${state.collections.length} collections', + context.l10n.formatCollectionsCount(state.collections.length), style: theme.textTheme.bodySmall!.copyWith(color: theme.colorScheme.onSurface), ), Text( totalRepoRecords == null - ? (state.isCollectionCountsLoading ? 'Counting records...' : 'Record counts unavailable') - : '$totalRepoRecords records', + ? (state.isCollectionCountsLoading + ? context.l10n.messageRecordCountsLoading + : context.l10n.messageRecordCountsUnavailable) + : context.l10n.formatRecordsCount(totalRepoRecords), style: context.textTheme.bodySmall!.copyWith(color: context.colorScheme.onSurfaceVariant), ), ], @@ -430,7 +439,7 @@ class _RepoOverview extends StatelessWidget { Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 8), child: Text( - 'COLLECTIONS', + context.l10n.labelCollections, style: context.textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w600, letterSpacing: 0.5), ), ), @@ -556,8 +565,8 @@ class _RecordsListState extends State<_RecordsList> { ), Text( selectedCollection?.recordCount == null - ? '${records.length} loaded' - : '${records.length} of ${selectedCollection!.recordCount}', + ? context.l10n.formatLoadedRecordsCount(records.length) + : context.l10n.formatLoadedRecordsOfTotal(records.length, selectedCollection!.recordCount!), style: context.textTheme.bodySmall, ), ], @@ -655,7 +664,7 @@ class _RecordInspector extends StatelessWidget { if (record.cid != null) ...[ const SizedBox(height: 2), Text( - 'CID: ${record.cid!}', + context.l10n.formatCid(record.cid!), style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.secondary), ), ], @@ -666,11 +675,14 @@ class _RecordInspector extends StatelessWidget { children: [ TextButton.icon( icon: const Icon(Icons.copy, size: 16), - label: const Text('Copy JSON'), + label: Text(context.l10n.buttonCopyJson), onPressed: () { Clipboard.setData(ClipboardData(text: jsonString)); ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('JSON copied to clipboard'), behavior: SnackBarBehavior.floating), + SnackBar( + content: Text(context.l10n.messageJsonCopiedToClipboard), + behavior: SnackBarBehavior.floating, + ), ); }, ), diff --git a/lib/features/feed/presentation/home_feed_screen.dart b/lib/features/feed/presentation/home_feed_screen.dart index fb91c0b..2685aeb 100644 --- a/lib/features/feed/presentation/home_feed_screen.dart +++ b/lib/features/feed/presentation/home_feed_screen.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import 'package:lazurite/core/l10n/l10n.dart'; import 'package:lazurite/core/theme/animation_tokens.dart'; import 'package:lazurite/core/theme/animation_utils.dart'; import 'package:lazurite/core/widgets/lazurite_app_bar.dart'; @@ -105,7 +106,7 @@ class _HomeFeedScreenState extends State { actions: [ IconButton( icon: const Icon(Icons.trending_up_outlined), - tooltip: 'Trending', + tooltip: context.l10n.labelTrending, onPressed: () => context.push('/trending'), ), IconButton( diff --git a/lib/features/feed/presentation/post_thread_screen.dart b/lib/features/feed/presentation/post_thread_screen.dart index 1bcf89c..018ee9c 100644 --- a/lib/features/feed/presentation/post_thread_screen.dart +++ b/lib/features/feed/presentation/post_thread_screen.dart @@ -8,6 +8,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; +import 'package:lazurite/core/l10n/l10n.dart'; import 'package:lazurite/core/theme/theme_extensions.dart'; import 'package:lazurite/features/compose/presentation/compose_route_args.dart'; import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; @@ -1036,9 +1037,9 @@ class _FocusedPostContent extends StatelessWidget { Future _confirmDelete(BuildContext context) async { await showConfirmationDialog( context: context, - title: const Text('Delete Post?'), - content: const Text('This action cannot be undone.'), - confirmLabel: 'Delete', + title: Text(context.l10n.dialogDeletePostTitle), + content: Text(context.l10n.dialogDeletePostContent), + confirmLabel: context.l10n.buttonDelete, confirmDestructive: true, onConfirmed: () => context.read().deletePost(), ); diff --git a/lib/features/feed/presentation/saved_posts_screen.dart b/lib/features/feed/presentation/saved_posts_screen.dart index e48cec1..36f4c3a 100644 --- a/lib/features/feed/presentation/saved_posts_screen.dart +++ b/lib/features/feed/presentation/saved_posts_screen.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:lazurite/core/database/app_database.dart'; +import 'package:lazurite/core/l10n/l10n.dart'; import 'package:lazurite/core/logging/app_logger.dart'; import 'package:lazurite/core/network/app_view_provider.dart'; import 'package:lazurite/core/network/app_view_web_links.dart'; @@ -22,6 +23,7 @@ import 'package:lazurite/shared/presentation/widgets/empty_state.dart'; import 'package:lazurite/shared/presentation/widgets/error_state.dart'; import 'package:lazurite/shared/presentation/widgets/loading_state.dart'; import 'package:lazurite/shared/presentation/widgets/staggered_entrance.dart'; +import 'package:lazurite/shared/utils/format_utils.dart'; enum SavedPostsInitialTab { bookmarks, liked, search } @@ -72,13 +74,13 @@ class _SavedPostsContentState extends State<_SavedPostsContent> with SingleTicke Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: const Text('Bookmarks & Likes'), + title: Text(context.l10n.labelBookmarksAndLikes), bottom: TabBar( controller: _tabController, - tabs: const [ - Tab(text: 'Bookmarks'), - Tab(text: 'Liked'), - Tab(text: 'Search'), + tabs: [ + Tab(text: context.l10n.labelBookmarks), + Tab(text: context.l10n.labelLiked), + Tab(text: context.l10n.labelSearch), ], ), ), @@ -111,8 +113,8 @@ class _AllSavedTabState extends State<_AllSavedTab> { if (state.status == SavedPostsStatus.error) { return ErrorState( - title: 'Failed to load bookmarks', - message: state.error ?? 'Unknown error', + title: context.l10n.errorFailedToLoadBookmarks, + message: state.error ?? context.l10n.errorUnknown, onRetry: () => context.read().loadSavedPosts(), ); } @@ -125,9 +127,9 @@ class _AllSavedTabState extends State<_AllSavedTab> { .toList(growable: false); if (localPosts.isEmpty && cloudPosts.isEmpty) { - return const EmptyState( - message: 'No bookmarks', - subtitle: 'Posts you bookmark will appear here', + return EmptyState( + message: context.l10n.messageNoBookmarks, + subtitle: context.l10n.messageNoBookmarksSubtitle, icon: Icons.bookmark_outline, ); } @@ -140,30 +142,30 @@ class _AllSavedTabState extends State<_AllSavedTab> { padding: const EdgeInsets.fromLTRB(12, 8, 8, 0), child: Row( children: [ - const Expanded( + Expanded( child: TabBar( isScrollable: true, tabAlignment: TabAlignment.start, tabs: [ - Tab(text: 'Local'), - Tab(text: 'Bluesky'), + Tab(text: context.l10n.labelLocal), + Tab(text: context.l10n.labelBluesky), ], ), ), if (localPosts.isNotEmpty) PopupMenuButton<_BookmarksMenuAction>( - tooltip: 'Bookmark actions', + tooltip: context.l10n.labelBookmarkActions, onSelected: (action) { if (action == _BookmarksMenuAction.clearLocal) { _confirmClearLocal(context); } }, - itemBuilder: (context) => const [ + itemBuilder: (context) => [ PopupMenuItem( value: _BookmarksMenuAction.clearLocal, child: ListTile( - leading: Icon(Icons.delete_sweep_outlined), - title: Text('Clear local bookmarks'), + leading: const Icon(Icons.delete_sweep_outlined), + title: Text(context.l10n.labelClearLocalBookmarks), contentPadding: EdgeInsets.zero, dense: true, ), @@ -205,11 +207,11 @@ class _AllSavedTabState extends State<_AllSavedTab> { return AnimatedRefreshIndicator( onRefresh: onRefresh, child: ListView( - children: const [ - SizedBox(height: 80), + children: [ + const SizedBox(height: 80), EmptyState( - message: 'No bookmarks in this source', - subtitle: 'Try switching tabs or saving posts to this source', + message: context.l10n.messageNoBookmarksInSource, + subtitle: context.l10n.messageNoBookmarksInSourceSubtitle, icon: Icons.bookmark_border, ), ], @@ -241,12 +243,10 @@ class _AllSavedTabState extends State<_AllSavedTab> { showDialog( context: context, builder: (dialogContext) => AlertDialog( - title: const Text('Clear local bookmarks?'), - content: const Text( - 'This removes only local bookmarks from this device. Bluesky cloud bookmarks will not be deleted.', - ), + title: Text(context.l10n.dialogClearLocalBookmarksTitle), + content: Text(context.l10n.dialogClearLocalBookmarksContent), actions: [ - TextButton(onPressed: () => Navigator.pop(dialogContext), child: const Text('Cancel')), + TextButton(onPressed: () => Navigator.pop(dialogContext), child: Text(context.l10n.buttonCancel)), FilledButton( onPressed: () { Navigator.pop(dialogContext); @@ -256,7 +256,7 @@ class _AllSavedTabState extends State<_AllSavedTab> { backgroundColor: context.colorScheme.error, foregroundColor: context.colorScheme.onError, ), - child: const Text('Clear Local'), + child: Text(context.l10n.buttonClearLocal), ), ], ), @@ -294,7 +294,7 @@ class _LikedPostsTabState extends State<_LikedPostsTab> { } catch (_) { setState(() { _isLoading = false; - _error = 'Liked posts are unavailable right now.'; + _error = context.l10n.messageLikedPostsUnavailable; }); return; } @@ -325,7 +325,7 @@ class _LikedPostsTabState extends State<_LikedPostsTab> { } setState(() { _isLoading = false; - _error = 'Failed to load liked posts: $e'; + _error = context.l10n.errorFailedToLoadLikedPostsDetails(e); }); } } @@ -368,14 +368,14 @@ class _LikedPostsTabState extends State<_LikedPostsTab> { setState(() { _isLoading = false; _isSyncing = false; - _syncWarning = 'Failed to refresh liked posts: $e'; + _syncWarning = context.l10n.errorFailedToRefreshLikedPosts(e); }); return; } setState(() { _isLoading = false; _isSyncing = false; - _error = 'Failed to load liked posts: $e'; + _error = context.l10n.errorFailedToLoadLikedPostsDetails(e); }); } } @@ -398,18 +398,22 @@ class _LikedPostsTabState extends State<_LikedPostsTab> { } if (_error != null && _likedPosts.isEmpty) { - return ErrorState(title: 'Failed to load liked posts', message: _error!, onRetry: () => _syncAndReload()); + return ErrorState( + title: context.l10n.errorFailedToLoadLikedPosts, + message: _error!, + onRetry: () => _syncAndReload(), + ); } if (_likedPosts.isEmpty) { return AnimatedRefreshIndicator( onRefresh: _syncAndReload, child: ListView( - children: const [ - SizedBox(height: 80), + children: [ + const SizedBox(height: 80), EmptyState( - message: 'No liked posts', - subtitle: 'Posts you like will appear here after sync', + message: context.l10n.messageNoLikedPosts, + subtitle: context.l10n.messageNoLikedPostsSubtitle, icon: Icons.favorite_outline, ), ], @@ -510,15 +514,18 @@ class _SavedPostCard extends StatelessWidget { margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: ListTile( leading: const Icon(Icons.bookmark), - title: const Text('Bookmarked Post'), - subtitle: Text('Saved on ${_formatDate(savedPost.savedAt)}', style: context.textTheme.bodySmall), + title: Text(context.l10n.labelBookmarkedPost), + subtitle: Text( + context.l10n.formatSavedOn(_formatDate(context, savedPost.savedAt)), + style: context.textTheme.bodySmall, + ), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( icon: const Icon(Icons.open_in_new), onPressed: () => context.push('/post?uri=${Uri.encodeQueryComponent(savedPost.postUri)}'), - tooltip: 'Open post', + tooltip: context.l10n.labelOpenPost, ), IconButton( icon: const Icon(Icons.share_outlined), @@ -526,9 +533,9 @@ class _SavedPostCard extends StatelessWidget { context, AppViewWebLinks.postFromAtUri(savedPost.postUri, appViewProvider: _resolveAppViewProvider(context)), ), - tooltip: 'Share', + tooltip: context.l10n.buttonShare, ), - IconButton(icon: const Icon(Icons.delete_outline), onPressed: onUnsave, tooltip: 'Remove'), + IconButton(icon: const Icon(Icons.delete_outline), onPressed: onUnsave, tooltip: context.l10n.buttonRemove), ], ), ), @@ -589,17 +596,20 @@ class _LikedPostCard extends StatelessWidget { margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: ListTile( leading: const Icon(Icons.favorite_outline), - title: const Text('Liked Post'), - subtitle: Text('Liked on ${_formatDate(likedPost.likedAt)}', style: context.textTheme.bodySmall), + title: Text(context.l10n.labelLikedPost), + subtitle: Text( + context.l10n.formatLikedOn(_formatDate(context, likedPost.likedAt)), + style: context.textTheme.bodySmall, + ), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ IconButton( icon: const Icon(Icons.open_in_new), onPressed: () => context.push('/post?uri=${Uri.encodeQueryComponent(likedPost.postUri)}'), - tooltip: 'Open post', + tooltip: context.l10n.labelOpenPost, ), - IconButton(icon: const Icon(Icons.delete_outline), onPressed: onRemove, tooltip: 'Remove'), + IconButton(icon: const Icon(Icons.delete_outline), onPressed: onRemove, tooltip: context.l10n.buttonRemove), ], ), ), @@ -607,13 +617,5 @@ class _LikedPostCard extends StatelessWidget { } } -String _formatDate(DateTime date) { - final now = DateTime.now(); - final difference = now.difference(date); - - if (difference.inMinutes < 1) return 'just now'; - if (difference.inHours < 1) return '${difference.inMinutes}m ago'; - if (difference.inDays < 1) return '${difference.inHours}h ago'; - if (difference.inDays < 7) return '${difference.inDays}d ago'; - return '${date.month}/${date.day}/${date.year}'; -} +String _formatDate(BuildContext context, DateTime date) => + formatRelativeTime(date, includeAgo: true, locale: Localizations.localeOf(context).toString()); diff --git a/lib/features/feed/presentation/trending_screen.dart b/lib/features/feed/presentation/trending_screen.dart index b7dc736..4949161 100644 --- a/lib/features/feed/presentation/trending_screen.dart +++ b/lib/features/feed/presentation/trending_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import 'package:lazurite/core/l10n/l10n.dart'; import 'package:lazurite/core/network/app_view_provider.dart'; import 'package:lazurite/core/network/app_view_router.dart'; import 'package:lazurite/core/widgets/lazurite_app_bar.dart'; @@ -22,7 +23,7 @@ class TrendingScreen extends StatefulWidget { class _TrendingScreenState extends State { TrendingScreenData? _data; bool _loading = true; - String? _errorMessage; + Object? _error; @override void initState() { @@ -37,7 +38,7 @@ class _TrendingScreenState extends State { setState(() { _loading = true; - _errorMessage = null; + _error = null; }); try { @@ -56,7 +57,7 @@ class _TrendingScreenState extends State { } setState(() { - _errorMessage = 'Failed to load trending topics: $error'; + _error = error; _loading = false; }); } @@ -79,23 +80,28 @@ class _TrendingScreenState extends State { @override Widget build(BuildContext context) { return Scaffold( - appBar: const LazuriteAppBar(sectionLabel: 'Trending'), + appBar: LazuriteAppBar(sectionLabel: context.l10n.labelTrending), body: _buildBody(context), ); } Widget _buildBody(BuildContext context) { if (_loading) { - return const LoadingState(message: 'Loading trending topics'); + return LoadingState(message: context.l10n.messageLoadingTrendingTopics); } - if (_errorMessage != null) { - return ErrorState(title: 'Failed to load trending', message: _errorMessage!, onRetry: _load); + final error = _error; + if (error != null) { + return ErrorState( + title: context.l10n.errorFailedToLoadTrending, + message: context.l10n.errorFailedToLoadTrendingTopics(error), + onRetry: _load, + ); } final data = _data; if (data == null || data.isEmpty) { - return const EmptyState(icon: Icons.trending_up_outlined, message: 'No trending topics right now'); + return EmptyState(icon: Icons.trending_up_outlined, message: context.l10n.messageNoTrendingTopicsRightNow); } final rows = [ @@ -109,12 +115,12 @@ class _TrendingScreenState extends State { borderRadius: BorderRadius.circular(12), ), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - child: const Text('Metadata temporarily unavailable'), + child: Text(context.l10n.messageMetadataTemporarilyUnavailable), ), ), - const _SectionHeader(title: 'Topics'), + _SectionHeader(title: context.l10n.labelTopics), ...data.topics.map((item) => _TrendTile(item: item, onTap: () => _onTapTopic(item))), - if (data.suggested.isNotEmpty) const _SectionHeader(title: 'Suggested'), + if (data.suggested.isNotEmpty) _SectionHeader(title: context.l10n.labelSuggested), ...data.suggested.map((item) => _TrendTile(item: item, onTap: () => _onTapTopic(item))), const SizedBox(height: 16), ]; @@ -155,9 +161,9 @@ class _TrendTile extends StatelessWidget { subtitleLines.add(description); } if (trend != null) { - subtitleLines.add('${trend.postCount} posts'); + subtitleLines.add(context.l10n.formatTrendingPostCount(trend.postCount)); if (trend.category != null && trend.category!.trim().isNotEmpty) { - subtitleLines.add('Category: ${trend.category}'); + subtitleLines.add(context.l10n.formatTrendingCategory(trend.category!)); } } diff --git a/lib/features/feed/presentation/widgets/compact_post_card.dart b/lib/features/feed/presentation/widgets/compact_post_card.dart index c6006a9..3e72e75 100644 --- a/lib/features/feed/presentation/widgets/compact_post_card.dart +++ b/lib/features/feed/presentation/widgets/compact_post_card.dart @@ -2,6 +2,7 @@ import 'package:bluesky/app_bsky_actor_defs.dart'; import 'package:bluesky/app_bsky_feed_defs.dart'; import 'package:bluesky/moderation.dart' as bsky_moderation; import 'package:flutter/material.dart'; +import 'package:lazurite/core/l10n/l10n.dart'; import 'package:lazurite/core/theme/theme_extensions.dart'; import 'package:lazurite/features/feed/presentation/widgets/facet_text.dart'; import 'package:lazurite/features/feed/presentation/widgets/post_embed_view.dart'; @@ -104,7 +105,7 @@ class CompactPostCard extends StatelessWidget { ), const SizedBox(height: 2), Text( - '@${author.handle} · ${formatRelativeTime(createdAt)}', + '@${author.handle} · ${formatRelativeTime(createdAt, nowLabel: context.l10n.commonNow)}', style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceVariant), ), ], diff --git a/lib/features/feed/presentation/widgets/grid_post_card.dart b/lib/features/feed/presentation/widgets/grid_post_card.dart index 2a6fdd9..a22fa2b 100644 --- a/lib/features/feed/presentation/widgets/grid_post_card.dart +++ b/lib/features/feed/presentation/widgets/grid_post_card.dart @@ -5,6 +5,7 @@ import 'package:bluesky/moderation.dart' as bsky_moderation; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:lazurite/core/cache/lazurite_image_cache.dart'; +import 'package:lazurite/core/l10n/l10n.dart'; import 'package:lazurite/core/theme/color_filters.dart'; import 'package:lazurite/core/theme/spacing.dart'; import 'package:lazurite/core/theme/theme_extensions.dart'; @@ -63,7 +64,9 @@ class GridPostCard extends StatelessWidget { ? PostEmbedView(feedViewPost: feedViewPost, embed: post.embed!, compact: isCompactGrid) : null; - final resolvedFooter = footer ?? PostCardFooter(timestamp: formatPostTime(record?.createdAt ?? post.indexedAt)); + final resolvedFooter = + footer ?? + PostCardFooter(timestamp: formatPostTime(record?.createdAt ?? post.indexedAt, nowLabel: context.l10n.labelNow)); return Container( decoration: BoxDecoration( @@ -236,7 +239,7 @@ class GridPostCard extends StatelessWidget { const SizedBox(width: 6), Expanded( child: Text( - 'Reply in a thread', + context.l10n.messageReplyInThread, maxLines: 1, overflow: TextOverflow.ellipsis, style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceVariant), @@ -259,7 +262,7 @@ class GridPostCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Replying to @${parentPost.author.handle}', + context.l10n.formatReplyingToHandle(parentPost.author.handle), maxLines: 1, overflow: TextOverflow.ellipsis, style: context.textTheme.labelSmall?.copyWith(color: context.colorScheme.onSurfaceVariant), diff --git a/lib/features/feed/presentation/widgets/post_action_bar.dart b/lib/features/feed/presentation/widgets/post_action_bar.dart index 074f6ea..1465060 100644 --- a/lib/features/feed/presentation/widgets/post_action_bar.dart +++ b/lib/features/feed/presentation/widgets/post_action_bar.dart @@ -1,10 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lazurite/core/l10n/l10n.dart'; import 'package:lazurite/core/network/app_view_provider.dart'; import 'package:lazurite/core/network/app_view_web_links.dart'; import 'package:lazurite/core/theme/animation_tokens.dart'; -import 'package:lazurite/features/connectivity/connectivity_helpers.dart'; import 'package:lazurite/features/settings/bloc/settings_cubit.dart'; import 'package:lazurite/shared/presentation/helpers/haptic_helper.dart'; import 'package:lazurite/shared/presentation/helpers/share_helper.dart'; @@ -74,7 +74,7 @@ class PostActionBar extends StatelessWidget { activeIcon: Icons.chat_bubble, count: replyCount, onTap: isOffline ? null : onReply, - tooltip: isOffline ? offlineActionMessage('reply to this post') : null, + tooltip: isOffline ? context.l10n.formatOfflineReconnectAction(context.l10n.actionReplyToThisPost) : null, color: context.colorScheme.onSurfaceVariant, ), _ActionButton( @@ -87,7 +87,7 @@ class PostActionBar extends StatelessWidget { onTap: isOffline ? null : onRepost, activeColor: Colors.green, onLongPress: !isOffline && onRepost != null ? () => _showRepostOptions(context) : null, - tooltip: isOffline ? offlineActionMessage('repost this post') : null, + tooltip: isOffline ? context.l10n.formatOfflineReconnectAction(context.l10n.actionRepostThisPost) : null, ), _ActionButton( icon: Icons.favorite_outline, @@ -98,7 +98,7 @@ class PostActionBar extends StatelessWidget { animateOnTap: true, onTap: isOffline ? null : onLike, activeColor: Colors.pink, - tooltip: isOffline ? offlineActionMessage('like this post') : null, + tooltip: isOffline ? context.l10n.formatOfflineReconnectAction(context.l10n.actionLikeThisPost) : null, ), _ActionButton( icon: isSaved ? Icons.bookmark : Icons.bookmark_outline, @@ -137,15 +137,15 @@ class PostActionBar extends StatelessWidget { items: [ OptionsSheetItem( leading: Icon(Icons.repeat, color: isReposted ? Colors.green : null), - title: isReposted ? 'Unrepost' : 'Repost', - subtitle: isReposted ? 'Remove this repost' : 'Share this post', + title: isReposted ? context.l10n.labelUnrepost : context.l10n.labelRepost, + subtitle: isReposted ? context.l10n.messageRemoveRepostSubtitle : context.l10n.messageShareThisPostSubtitle, onTap: onRepost, ), if (!isReposted) OptionsSheetItem( leading: const Icon(Icons.format_quote), - title: 'Quote Post', - subtitle: 'Quote this post with your own text', + title: context.l10n.labelQuotePost, + subtitle: context.l10n.messageQuotePostSubtitle, onTap: onQuote, ), ], @@ -164,7 +164,7 @@ class PostActionBar extends StatelessWidget { isLocalSaved ? Icons.bookmark_remove_outlined : Icons.bookmark_add_outlined, color: Colors.amber, ), - title: isLocalSaved ? 'Remove local save' : 'Save locally', + title: isLocalSaved ? context.l10n.labelRemoveLocalSave : context.l10n.labelSaveLocally, onTap: onSave, ), OptionsSheetItem( @@ -172,7 +172,7 @@ class PostActionBar extends StatelessWidget { isCloudSaved ? Icons.cloud_off_outlined : Icons.cloud_outlined, color: context.colorScheme.primary, ), - title: isCloudSaved ? 'Remove from Bluesky' : 'Save to Bluesky', + title: isCloudSaved ? context.l10n.labelRemoveFromBluesky : context.l10n.labelSaveToBluesky, onTap: isCloudSaved ? onCloudUnsave : onCloudSave, ), ], diff --git a/lib/features/feed/presentation/widgets/post_card.dart b/lib/features/feed/presentation/widgets/post_card.dart index 3086afe..555c94a 100644 --- a/lib/features/feed/presentation/widgets/post_card.dart +++ b/lib/features/feed/presentation/widgets/post_card.dart @@ -2,6 +2,7 @@ import 'package:bluesky/app_bsky_actor_defs.dart'; import 'package:bluesky/app_bsky_feed_defs.dart'; import 'package:bluesky/moderation.dart' as bsky_moderation; import 'package:flutter/material.dart'; +import 'package:lazurite/core/l10n/l10n.dart'; import 'package:lazurite/core/theme/theme_extensions.dart'; import 'package:lazurite/features/feed/presentation/widgets/facet_text.dart'; import 'package:lazurite/features/feed/presentation/widgets/post_card_footer.dart'; @@ -44,7 +45,9 @@ class PostCard extends StatelessWidget { final moderationService = maybeModerationService(context); final postUi = moderationService?.postUi(post, moderationContext) ?? const bsky_moderation.ModerationUI(); - final resolvedFooter = actionBar ?? PostCardFooter(timestamp: formatPostTime(record?.createdAt ?? post.indexedAt)); + final resolvedFooter = + actionBar ?? + PostCardFooter(timestamp: formatPostTime(record?.createdAt ?? post.indexedAt, nowLabel: context.l10n.labelNow)); return Container( margin: const EdgeInsets.symmetric(vertical: 1), @@ -134,7 +137,7 @@ class PostCard extends StatelessWidget { const SizedBox(width: 6), Flexible( child: Text( - 'Reply in a thread', + context.l10n.messageReplyInThread, maxLines: 1, overflow: TextOverflow.ellipsis, style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceVariant), @@ -157,7 +160,7 @@ class PostCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Replying to @${parentPost.author.handle}', + context.l10n.formatReplyingToHandle(parentPost.author.handle), style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceVariant), ), if (parentText.isNotEmpty) ...[ diff --git a/lib/features/feed/presentation/widgets/post_card_footer.dart b/lib/features/feed/presentation/widgets/post_card_footer.dart index 2624284..b536a72 100644 --- a/lib/features/feed/presentation/widgets/post_card_footer.dart +++ b/lib/features/feed/presentation/widgets/post_card_footer.dart @@ -1,13 +1,13 @@ import 'package:flutter/material.dart'; -import 'package:lazurite/features/connectivity/connectivity_helpers.dart'; +import 'package:lazurite/core/l10n/l10n.dart'; import 'package:lazurite/shared/presentation/helpers/haptic_helper.dart'; import 'package:lazurite/shared/presentation/widgets/options_sheet.dart'; import 'package:lazurite/shared/utils/format_utils.dart'; import 'package:lazurite/core/theme/theme_extensions.dart'; /// Formats a post timestamp as a short, uppercase string. -String formatPostTime(DateTime time) { - return formatRelativeTime(time, nowLabel: 'NOW', uppercase: true); +String formatPostTime(DateTime time, {String nowLabel = 'NOW'}) { + return formatRelativeTime(time, nowLabel: nowLabel, uppercase: true); } /// Shared footer for post cards. Renders a top-bordered row with @@ -93,7 +93,7 @@ class PostCardFooter extends StatelessWidget { verticalPadding: actionVerticalPadding, minTapTarget: minimumTapTarget, showCount: canShowCounts, - tooltip: isOffline ? offlineActionMessage('reply to this post') : null, + tooltip: isOffline ? context.l10n.formatOfflineReconnectAction(context.l10n.actionReplyToThisPost) : null, ), _FooterAction( icon: Icons.repeat, @@ -110,7 +110,7 @@ class PostCardFooter extends StatelessWidget { verticalPadding: actionVerticalPadding, minTapTarget: minimumTapTarget, showCount: canShowCounts, - tooltip: isOffline ? offlineActionMessage('repost this post') : null, + tooltip: isOffline ? context.l10n.formatOfflineReconnectAction(context.l10n.actionRepostThisPost) : null, ), _FooterAction( icon: Icons.favorite_outline, @@ -126,7 +126,7 @@ class PostCardFooter extends StatelessWidget { verticalPadding: actionVerticalPadding, minTapTarget: minimumTapTarget, showCount: canShowCounts, - tooltip: isOffline ? offlineActionMessage('like this post') : null, + tooltip: isOffline ? context.l10n.formatOfflineReconnectAction(context.l10n.actionLikeThisPost) : null, ), _FooterAction( icon: isSaved ? Icons.bookmark : Icons.bookmark_outline, @@ -194,15 +194,15 @@ class PostCardFooter extends StatelessWidget { items: [ OptionsSheetItem( leading: Icon(Icons.repeat, color: isReposted ? Colors.green : null), - title: isReposted ? 'Unrepost' : 'Repost', - subtitle: isReposted ? 'Remove this repost' : 'Share this post', + title: isReposted ? context.l10n.labelUnrepost : context.l10n.labelRepost, + subtitle: isReposted ? context.l10n.messageRemoveRepostSubtitle : context.l10n.messageShareThisPostSubtitle, onTap: onRepost, ), if (!isReposted) OptionsSheetItem( leading: const Icon(Icons.format_quote), - title: 'Quote Post', - subtitle: 'Quote this post with your own text', + title: context.l10n.labelQuotePost, + subtitle: context.l10n.messageQuotePostSubtitle, onTap: onQuote, ), ], @@ -270,7 +270,7 @@ class PostCardFooter extends StatelessWidget { isLocalSaved ? Icons.bookmark_remove_outlined : Icons.bookmark_add_outlined, color: Colors.amber, ), - title: isLocalSaved ? 'Remove local save' : 'Save locally', + title: isLocalSaved ? context.l10n.labelRemoveLocalSave : context.l10n.labelSaveLocally, onTap: onSave, ), OptionsSheetItem( @@ -278,7 +278,7 @@ class PostCardFooter extends StatelessWidget { isCloudSaved ? Icons.cloud_off_outlined : Icons.cloud_outlined, color: context.colorScheme.primary, ), - title: isCloudSaved ? 'Remove from Bluesky' : 'Save to Bluesky', + title: isCloudSaved ? context.l10n.labelRemoveFromBluesky : context.l10n.labelSaveToBluesky, onTap: isCloudSaved ? onCloudUnsave : onCloudSave, ), ], diff --git a/lib/features/feed/presentation/widgets/post_card_with_actions.dart b/lib/features/feed/presentation/widgets/post_card_with_actions.dart index c7da46d..52737db 100644 --- a/lib/features/feed/presentation/widgets/post_card_with_actions.dart +++ b/lib/features/feed/presentation/widgets/post_card_with_actions.dart @@ -6,6 +6,7 @@ import 'package:bluesky/app_bsky_feed_post.dart'; import 'package:bluesky/moderation.dart' as bsky_moderation; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lazurite/core/l10n/l10n.dart'; import 'package:go_router/go_router.dart'; import 'package:lazurite/core/theme/feed_layout.dart'; import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; @@ -145,7 +146,7 @@ class _PostCardWithActionsContent extends StatelessWidget { (previous.error != current.error && current.error != null) || (!previous.isDeleted && current.isDeleted), listener: (context, state) { if (state.isDeleted) { - showAppSnackBar(context, 'Post deleted', behavior: SnackBarBehavior.floating); + showAppSnackBar(context, context.l10n.messagePostDeleted, behavior: SnackBarBehavior.floating); onDeleted?.call(); return; } @@ -156,7 +157,7 @@ class _PostCardWithActionsContent extends StatelessWidget { context, error, behavior: SnackBarBehavior.floating, - actionLabel: 'Retry', + actionLabel: context.l10n.buttonRetry, onAction: () { if (error.contains('like')) { cubit.toggleLike(); @@ -222,7 +223,7 @@ class _PostCardWithActionsContent extends StatelessWidget { return BlocBuilder( builder: (context, savedState) { return PostCardFooter( - timestamp: formatPostTime(post.indexedAt), + timestamp: formatPostTime(post.indexedAt, nowLabel: context.l10n.labelNow), replyCount: post.replyCount ?? 0, repostCount: postActionState.repostCount, likeCount: postActionState.likeCount, @@ -365,9 +366,9 @@ class _PostCardWithActionsContent extends StatelessWidget { Future _confirmDelete(BuildContext context) async { await showConfirmationDialog( context: context, - title: const Text('Delete Post?'), - content: const Text('This action cannot be undone.'), - confirmLabel: 'Delete', + title: Text(context.l10n.dialogDeletePostTitle), + content: Text(context.l10n.dialogDeletePostContent), + confirmLabel: context.l10n.buttonDelete, confirmDestructive: true, onConfirmed: () => context.read().deletePost(), ); diff --git a/lib/features/feed/presentation/widgets/post_embed_view.dart b/lib/features/feed/presentation/widgets/post_embed_view.dart index 34dd751..45627b7 100644 --- a/lib/features/feed/presentation/widgets/post_embed_view.dart +++ b/lib/features/feed/presentation/widgets/post_embed_view.dart @@ -9,6 +9,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:lazurite/core/cache/lazurite_image_cache.dart'; +import 'package:lazurite/core/l10n/l10n.dart'; import 'package:lazurite/core/theme/theme_extensions.dart'; import 'package:lazurite/features/feed/presentation/media/image_viewer_route_args.dart'; import 'package:lazurite/features/feed/presentation/media/media_actions.dart'; @@ -271,13 +272,13 @@ class PostEmbedView extends StatelessWidget { } if (record.isEmbedRecordViewNotFound) { - return _buildUnavailableQuote(context, 'Quoted post not found'); + return _buildUnavailableQuote(context, context.l10n.messageQuotedPostNotFound); } if (record.isEmbedRecordViewBlocked) { - return _buildUnavailableQuote(context, 'Quoted post is blocked'); + return _buildUnavailableQuote(context, context.l10n.messageQuotedPostBlocked); } if (record.isEmbedRecordViewDetached) { - return _buildUnavailableQuote(context, 'Quoted post is unavailable'); + return _buildUnavailableQuote(context, context.l10n.messageQuotedPostUnavailable); } return const SizedBox.shrink(); @@ -359,9 +360,12 @@ class PostEmbedView extends StatelessWidget { final selected = await showMenu<_ImageThumbnailAction>( context: context, position: RelativeRect.fromLTRB(globalPosition.dx, globalPosition.dy, globalPosition.dx, globalPosition.dy), - items: const [ - PopupMenuItem<_ImageThumbnailAction>(value: _ImageThumbnailAction.save, child: Text('Save image')), - PopupMenuItem<_ImageThumbnailAction>(value: _ImageThumbnailAction.share, child: Text('Share')), + items: [ + PopupMenuItem<_ImageThumbnailAction>( + value: _ImageThumbnailAction.save, + child: Text(context.l10n.labelSaveImage), + ), + PopupMenuItem<_ImageThumbnailAction>(value: _ImageThumbnailAction.share, child: Text(context.l10n.buttonShare)), ], ); diff --git a/lib/features/feed/presentation/widgets/post_interactions_sheet.dart b/lib/features/feed/presentation/widgets/post_interactions_sheet.dart index 646ae0f..e517a59 100644 --- a/lib/features/feed/presentation/widgets/post_interactions_sheet.dart +++ b/lib/features/feed/presentation/widgets/post_interactions_sheet.dart @@ -2,6 +2,7 @@ import 'package:atproto_core/atproto_core.dart'; import 'package:bluesky/app_bsky_actor_defs.dart'; import 'package:flutter/material.dart'; import 'package:lazurite/core/cache/lazurite_image_cache.dart'; +import 'package:lazurite/core/l10n/l10n.dart'; import 'package:lazurite/core/theme/theme_extensions.dart'; import 'package:lazurite/features/feed/data/post_action_repository.dart'; import 'package:lazurite/shared/presentation/helpers/navigation_helpers.dart'; @@ -140,7 +141,7 @@ class _PostInteractionsSheetState extends State { } Widget _buildSectionLabel(BuildContext context, ColorScheme colorScheme) { - final label = _selectedTab == InteractionTab.likes ? 'LIKED BY' : 'REPOSTED BY'; + final label = _selectedTab == InteractionTab.likes ? context.l10n.labelLikedBy : context.l10n.labelRepostedBy; return Padding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), child: Text( @@ -164,14 +165,14 @@ class _PostInteractionsSheetState extends State { colorScheme: colorScheme, tab: InteractionTab.likes, icon: Icons.favorite_outline, - label: '${widget.likeCount} Likes', + label: context.l10n.formatLikesCount(widget.likeCount), ), const SizedBox(width: 10), _buildTabChip( colorScheme: colorScheme, tab: InteractionTab.reposts, icon: Icons.repeat, - label: '${widget.repostCount} Reposts', + label: context.l10n.formatRepostsCount(widget.repostCount), ), ], ), @@ -229,7 +230,7 @@ class _PostInteractionsSheetState extends State { if (loaded && profiles.isEmpty) { return Center( - child: Text('No interactions yet', style: TextStyle(color: colorScheme.onSurfaceVariant)), + child: Text(context.l10n.messageNoInteractionsYet, style: TextStyle(color: colorScheme.onSurfaceVariant)), ); } diff --git a/lib/features/feed/presentation/widgets/post_menu_actions.dart b/lib/features/feed/presentation/widgets/post_menu_actions.dart index a442e9c..cfe3028 100644 --- a/lib/features/feed/presentation/widgets/post_menu_actions.dart +++ b/lib/features/feed/presentation/widgets/post_menu_actions.dart @@ -2,6 +2,7 @@ import 'package:bluesky/app_bsky_feed_defs.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter/services.dart'; +import 'package:lazurite/core/l10n/l10n.dart'; import 'package:lazurite/core/network/app_view_provider.dart'; import 'package:lazurite/core/network/app_view_web_links.dart'; import 'package:lazurite/features/feed/data/post_action_repository.dart'; @@ -34,46 +35,46 @@ Future showPostOverflowMenu({ items: [ OptionsSheetItem( leading: const Icon(Icons.favorite_outline), - title: 'Show Liked Users', - subtitle: 'View who liked this post', + title: context.l10n.labelShowLikedUsers, + subtitle: context.l10n.messageShowLikedUsersSubtitle, enabled: !isOffline, onTap: () => showLikedUsersSheet(context: context, post: post, repository: repository), ), OptionsSheetItem( leading: const Icon(Icons.repeat), - title: 'Show Quote/Repost List', - subtitle: 'View quote posts and expand reposts', + title: context.l10n.labelShowQuoteRepostList, + subtitle: context.l10n.messageShowQuoteRepostListSubtitle, enabled: !isOffline, onTap: () => showQuoteRepostSheet(context: context, post: post, repository: repository), ), OptionsSheetItem( leading: const Icon(Icons.format_quote), - title: 'Quote Post', - subtitle: 'Quote this post with your own text', + title: context.l10n.labelQuotePost, + subtitle: context.l10n.messageQuotePostSubtitle, enabled: !isOffline, onTap: onQuote, ), OptionsSheetItem( leading: const Icon(Icons.copy), - title: 'Copy Link', + title: context.l10n.labelCopyLink, onTap: () => _copyToClipboard(context, bskyUrl), ), OptionsSheetItem( leading: const Icon(Icons.person_outline), - title: 'View @${post.author.handle}', + title: context.l10n.formatViewHandle(post.author.handle), onTap: () => navigateToProfile(context, post.author.did), ), OptionsSheetItem( leading: const Icon(Icons.report_outlined, color: Colors.orange), - title: 'Report Post', + title: context.l10n.labelReportPost, onTap: onShowReport, ), if (post.author.did == accountDid && onEdit != null) - OptionsSheetItem(leading: const Icon(Icons.edit_outlined), title: 'Edit Post', onTap: onEdit), + OptionsSheetItem(leading: const Icon(Icons.edit_outlined), title: context.l10n.labelEditPost, onTap: onEdit), if (post.author.did == accountDid && onDelete != null) OptionsSheetItem( leading: Icon(Icons.delete_outline, color: Theme.of(context).colorScheme.error), - title: 'Delete Post', + title: context.l10n.labelDeletePost, isDestructive: true, onTap: onDelete, ), @@ -118,7 +119,7 @@ void showQuoteRepostSheet({ void _copyToClipboard(BuildContext context, String text) { Clipboard.setData(ClipboardData(text: text)); - showAppSnackBar(context, 'Link copied to clipboard', behavior: SnackBarBehavior.floating); + showAppSnackBar(context, context.l10n.messageLinkCopiedToClipboard, behavior: SnackBarBehavior.floating); } String _resolveAppViewProvider(BuildContext context) { diff --git a/lib/features/feed/presentation/widgets/post_quote_repost_sheet.dart b/lib/features/feed/presentation/widgets/post_quote_repost_sheet.dart index 9aaa70a..b3d2921 100644 --- a/lib/features/feed/presentation/widgets/post_quote_repost_sheet.dart +++ b/lib/features/feed/presentation/widgets/post_quote_repost_sheet.dart @@ -5,6 +5,7 @@ import 'package:bluesky/app_bsky_feed_post.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:lazurite/core/cache/lazurite_image_cache.dart'; +import 'package:lazurite/core/l10n/l10n.dart'; import 'package:lazurite/core/theme/theme_extensions.dart'; import 'package:lazurite/features/feed/data/post_action_repository.dart'; import 'package:lazurite/shared/presentation/helpers/navigation_helpers.dart'; @@ -113,7 +114,7 @@ class _PostQuoteRepostSheetState extends State { padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), children: [ Text( - 'QUOTE / REPOSTS', + context.l10n.labelQuoteReposts, style: context.textTheme.labelSmall?.copyWith( color: colorScheme.onSurfaceVariant, fontWeight: FontWeight.w700, @@ -144,7 +145,10 @@ class _PostQuoteRepostSheetState extends State { Icon(Icons.format_quote, size: 16, color: colorScheme.onSurfaceVariant), const SizedBox(width: 8), Expanded( - child: Text('Quotes', style: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w700)), + child: Text( + context.l10n.labelQuotes, + style: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w700), + ), ), Text( '${widget.quoteCount}', @@ -162,7 +166,7 @@ class _PostQuoteRepostSheetState extends State { else if (_quotesLoaded && _quotes.isEmpty) Padding( padding: const EdgeInsets.all(16), - child: Text('No quotes yet', style: TextStyle(color: colorScheme.onSurfaceVariant)), + child: Text(context.l10n.messageNoQuotesYet, style: TextStyle(color: colorScheme.onSurfaceVariant)), ) else Column( @@ -173,7 +177,7 @@ class _PostQuoteRepostSheetState extends State { onPressed: _loadingQuotes ? null : _loadQuotes, child: _loadingQuotes ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) - : const Text('Load more quotes'), + : Text(context.l10n.buttonLoadMoreQuotes), ), ], ), @@ -193,7 +197,10 @@ class _PostQuoteRepostSheetState extends State { iconColor: colorScheme.onSurfaceVariant, collapsedIconColor: colorScheme.onSurfaceVariant, leading: Icon(Icons.repeat, color: colorScheme.onSurfaceVariant), - title: Text('Reposts', style: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w700)), + title: Text( + context.l10n.labelReposts, + style: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w700), + ), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -212,7 +219,7 @@ class _PostQuoteRepostSheetState extends State { else if (_repostsLoaded && _reposters.isEmpty) Padding( padding: const EdgeInsets.all(16), - child: Text('No reposts yet', style: TextStyle(color: colorScheme.onSurfaceVariant)), + child: Text(context.l10n.messageNoRepostsYet, style: TextStyle(color: colorScheme.onSurfaceVariant)), ) else Column( @@ -223,7 +230,7 @@ class _PostQuoteRepostSheetState extends State { onPressed: _loadingReposts ? null : _loadReposts, child: _loadingReposts ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) - : const Text('Load more reposts'), + : Text(context.l10n.buttonLoadMoreReposts), ), ], ), diff --git a/lib/features/lists/presentation/list_detail_screen.dart b/lib/features/lists/presentation/list_detail_screen.dart index 3a5fecd..10539ff 100644 --- a/lib/features/lists/presentation/list_detail_screen.dart +++ b/lib/features/lists/presentation/list_detail_screen.dart @@ -4,6 +4,8 @@ import 'package:bluesky/moderation.dart' as bsky_moderation; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import 'package:lazurite/core/l10n/l10n.dart'; +import 'package:lazurite/core/theme/theme_extensions.dart'; import 'package:lazurite/core/widgets/sliver_tab_bar_delegate.dart'; import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; import 'package:lazurite/features/feed/presentation/widgets/post_card_with_actions.dart'; @@ -14,7 +16,6 @@ import 'package:lazurite/features/lists/presentation/widgets/create_edit_list_di import 'package:lazurite/shared/presentation/helpers/navigation_helpers.dart'; import 'package:lazurite/shared/presentation/widgets/confirmation_dialog.dart'; import 'package:lazurite/shared/presentation/widgets/options_sheet.dart'; -import 'package:lazurite/core/theme/theme_extensions.dart'; class ListDetailScreen extends StatelessWidget { const ListDetailScreen({super.key, required this.listUri}); @@ -99,9 +100,9 @@ class _ListDetailViewState extends State<_ListDetailView> with SingleTickerProvi Future _confirmDelete(BuildContext context) async { final confirmed = await showConfirmationDialog( context: context, - title: const Text('Delete list?'), - content: const Text('This action cannot be undone.'), - confirmLabel: 'Delete', + title: Text(context.l10n.dialogDeleteListTitle), + content: Text(context.l10n.dialogDeletePostContent), + confirmLabel: context.l10n.buttonDelete, confirmDestructive: true, ); @@ -127,13 +128,13 @@ class _ListDetailViewState extends State<_ListDetailView> with SingleTickerProvi if (isOwn) OptionsSheetItem( leading: const Icon(Icons.edit_outlined), - title: 'Edit list', + title: context.l10n.labelEditList, onTap: () => _showEditDialog(context, list), ), if (isOwn) OptionsSheetItem( leading: const Icon(Icons.person_add_outlined), - title: 'Add members', + title: context.l10n.buttonAddMembers, onTap: () async { final listUriStr = Uri.encodeComponent(list.uri.toString()); await context.push('/list/members?uri=$listUriStr'); @@ -145,19 +146,19 @@ class _ListDetailViewState extends State<_ListDetailView> with SingleTickerProvi if (isOwn) OptionsSheetItem( leading: Icon(Icons.delete_outline, color: context.colorScheme.error), - title: 'Delete list', + title: context.l10n.dialogDeleteListTitle, isDestructive: true, onTap: () => _confirmDelete(context), ), OptionsSheetItem( leading: Icon(isMuted ? Icons.volume_up_outlined : Icons.volume_off_outlined), - title: isMuted ? 'Unmute list' : 'Mute list', + title: isMuted ? context.l10n.labelUnmuteList : context.l10n.labelMuteList, onTap: () => context.read().add(isMuted ? const ListUnmuted() : const ListMuted()), ), if (list.purpose.knownValue == bsky_graph.KnownListPurpose.appBskyGraphDefsModlist) OptionsSheetItem( leading: Icon(isBlocked ? Icons.block_flipped : Icons.block_outlined), - title: isBlocked ? 'Unblock via list' : 'Block via list', + title: isBlocked ? context.l10n.labelUnblockViaList : context.l10n.labelBlockViaList, onTap: () => context.read().add(isBlocked ? const ListUnblocked() : const ListBlocked()), ), ], @@ -188,7 +189,7 @@ class _ListDetailViewState extends State<_ListDetailView> with SingleTickerProvi floating: true, pinned: true, snap: true, - title: Text(list?.name ?? 'List'), + title: Text(list?.name ?? context.l10n.labelList), actions: [ if (state.isMutating) const Padding( @@ -215,7 +216,7 @@ class _ListDetailViewState extends State<_ListDetailView> with SingleTickerProvi SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.all(24), - child: Center(child: Text(state.errorMessage ?? 'Failed to load list')), + child: Center(child: Text(state.errorMessage ?? context.l10n.errorFailedToLoadList)), ), ), SliverPersistentHeader( @@ -223,9 +224,9 @@ class _ListDetailViewState extends State<_ListDetailView> with SingleTickerProvi delegate: SliverTabBarDelegate( TabBar( controller: _tabController, - tabs: const [ - Tab(text: 'FEED'), - Tab(text: 'MEMBERS'), + tabs: [ + Tab(text: context.l10n.labelFeed.toUpperCase()), + Tab(text: context.l10n.labelMembers.toUpperCase()), ], labelStyle: const TextStyle(fontSize: 11, fontWeight: FontWeight.w700, letterSpacing: 2.2), unselectedLabelStyle: const TextStyle( @@ -242,7 +243,7 @@ class _ListDetailViewState extends State<_ListDetailView> with SingleTickerProvi body: TabBarView( controller: _tabController, children: [ - isCuration ? _buildFeedTab(context) : _buildFeedUnavailableTab(), + isCuration ? _buildFeedTab(context) : _buildFeedUnavailableTab(context), _buildMembersTab(context, state), ], ), @@ -278,12 +279,12 @@ class _ListDetailViewState extends State<_ListDetailView> with SingleTickerProvi Text(list.name, style: textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700)), const SizedBox(height: 2), Text( - 'by @${list.creator.handle}', + context.l10n.formatListByHandle(list.creator.handle), style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), ), const SizedBox(height: 4), Text( - '${list.listItemCount ?? 0} members', + context.l10n.formatMemberCount(list.listItemCount ?? 0), style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), ), ], @@ -308,11 +309,11 @@ class _ListDetailViewState extends State<_ListDetailView> with SingleTickerProvi } if (feedState.hasError) { - return Center(child: Text(feedState.errorMessage ?? 'Failed to load feed')); + return Center(child: Text(feedState.errorMessage ?? context.l10n.errorFailedToLoadFeed)); } if (!feedState.hasPosts) { - return const Center(child: Text('No posts yet')); + return Center(child: Text(context.l10n.messageNoPostsYet)); } final accountDid = context.read().state.tokens?.did ?? ''; @@ -350,8 +351,8 @@ class _ListDetailViewState extends State<_ListDetailView> with SingleTickerProvi ); } - Widget _buildFeedUnavailableTab() { - return const Center(child: Text('Feed not available for moderation lists')); + Widget _buildFeedUnavailableTab(BuildContext context) { + return Center(child: Text(context.l10n.messageFeedUnavailableForModerationLists)); } Widget _buildMembersTab(BuildContext context, ListState state) { @@ -360,11 +361,11 @@ class _ListDetailViewState extends State<_ListDetailView> with SingleTickerProvi } if (state.hasError && !state.hasItems) { - return Center(child: Text(state.errorMessage ?? 'Failed to load members')); + return Center(child: Text(state.errorMessage ?? context.l10n.errorFailedToLoadMembers)); } if (!state.hasItems) { - return const Center(child: Text('No members yet')); + return Center(child: Text(context.l10n.messageNoMembersYet)); } final isOwn = _isOwnList(context, state.list); diff --git a/lib/features/lists/presentation/list_members_screen.dart b/lib/features/lists/presentation/list_members_screen.dart index 2c9a244..bc65d45 100644 --- a/lib/features/lists/presentation/list_members_screen.dart +++ b/lib/features/lists/presentation/list_members_screen.dart @@ -2,11 +2,12 @@ import 'package:atproto_core/atproto_core.dart' show AtUri; import 'package:bluesky/app_bsky_actor_defs.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lazurite/core/l10n/l10n.dart'; +import 'package:lazurite/core/theme/theme_extensions.dart'; import 'package:lazurite/features/lists/bloc/list_bloc.dart'; import 'package:lazurite/features/typeahead/data/typeahead_repository.dart'; import 'package:lazurite/shared/presentation/helpers/navigation_helpers.dart'; import 'package:lazurite/shared/presentation/widgets/staggered_entrance.dart'; -import 'package:lazurite/core/theme/theme_extensions.dart'; /// Screen for adding and removing members from a list. /// @@ -67,7 +68,7 @@ class _ListMembersViewState extends State<_ListMembersView> { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text('Add members')), + appBar: AppBar(title: Text(context.l10n.buttonAddMembers)), body: BlocBuilder( builder: (context, state) { return Column( @@ -77,7 +78,7 @@ class _ListMembersViewState extends State<_ListMembersView> { child: TextField( controller: _searchController, decoration: InputDecoration( - hintText: 'Search for people', + hintText: context.l10n.messageSearchForPeoplePlaceholder, prefixIcon: const Icon(Icons.search), suffixIcon: _isSearching ? const Padding( @@ -162,7 +163,7 @@ class _ListMembersViewState extends State<_ListMembersView> { } if (!state.hasItems) { - return const Center(child: Text('No members yet. Search above to add people.')); + return Center(child: Text(context.l10n.messageNoMembersYetSearch)); } final colorScheme = context.colorScheme; @@ -173,7 +174,7 @@ class _ListMembersViewState extends State<_ListMembersView> { Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: Text( - 'CURRENT MEMBERS', + context.l10n.labelCurrentMembers.toUpperCase(), style: TextStyle( fontSize: 11, fontWeight: FontWeight.w700, diff --git a/lib/features/lists/presentation/my_lists_screen.dart b/lib/features/lists/presentation/my_lists_screen.dart index f1bf955..cf294ad 100644 --- a/lib/features/lists/presentation/my_lists_screen.dart +++ b/lib/features/lists/presentation/my_lists_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import 'package:lazurite/core/l10n/l10n.dart'; import 'package:lazurite/core/theme/animation_tokens.dart'; import 'package:lazurite/core/theme/animation_utils.dart'; import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; @@ -82,12 +83,12 @@ class _MyListsViewState extends State<_MyListsView> with SingleTickerProviderSta Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: const Text('My Lists'), + title: Text(context.l10n.labelMyLists), bottom: TabBar( controller: _tabController, - tabs: const [ - Tab(text: 'FEEDS'), - Tab(text: 'MODERATION'), + tabs: [ + Tab(text: context.l10n.labelFeeds.toUpperCase()), + Tab(text: context.l10n.labelModeration.toUpperCase()), ], labelStyle: const TextStyle(fontSize: 11, fontWeight: FontWeight.w700, letterSpacing: 2.2), unselectedLabelStyle: const TextStyle(fontSize: 11, fontWeight: FontWeight.w700, letterSpacing: 2.2), @@ -102,8 +103,8 @@ class _MyListsViewState extends State<_MyListsView> with SingleTickerProviderSta if (state.status == MyListsStatus.error) { return ErrorState( - title: 'Failed to load lists', - message: state.errorMessage ?? 'Unknown error', + title: context.l10n.errorFailedToLoadLists, + message: state.errorMessage ?? context.l10n.errorUnknown, onRetry: () => context.read().refresh(), ); } @@ -131,7 +132,7 @@ class _MyListsViewState extends State<_MyListsView> with SingleTickerProviderSta Widget _buildListTab(BuildContext context, List lists) { if (lists.isEmpty) { - return const EmptyState(message: 'No lists yet', icon: Icons.list_alt_outlined); + return EmptyState(message: context.l10n.messageNoListsYet, icon: Icons.list_alt_outlined); } return AnimatedRefreshIndicator( diff --git a/lib/features/lists/presentation/widgets/create_edit_list_dialog.dart b/lib/features/lists/presentation/widgets/create_edit_list_dialog.dart index 54c4c10..7174178 100644 --- a/lib/features/lists/presentation/widgets/create_edit_list_dialog.dart +++ b/lib/features/lists/presentation/widgets/create_edit_list_dialog.dart @@ -1,5 +1,6 @@ import 'dart:io'; import 'dart:typed_data'; +import 'package:lazurite/core/l10n/l10n.dart'; import 'package:lazurite/core/theme/theme_extensions.dart'; import 'package:bluesky/app_bsky_graph_defs.dart' show KnownListPurpose; @@ -130,7 +131,7 @@ class _CreateEditListDialogState extends State { final hasAvatar = _avatarBytes != null || widget.initialAvatarUrl != null; return AlertDialog( - title: Text(_isEditing ? 'Edit list' : 'Create list'), + title: Text(_isEditing ? context.l10n.labelEditList : context.l10n.labelCreateList), content: SizedBox( width: double.maxFinite, child: SingleChildScrollView( @@ -176,33 +177,36 @@ class _CreateEditListDialogState extends State { const SizedBox(height: 16), TextField( controller: _nameController, - decoration: const InputDecoration(labelText: 'Name', border: OutlineInputBorder()), + decoration: InputDecoration(labelText: context.l10n.labelName, border: const OutlineInputBorder()), maxLength: 64, textCapitalization: TextCapitalization.sentences, ), const SizedBox(height: 12), TextField( controller: _descController, - decoration: const InputDecoration(labelText: 'Description (optional)', border: OutlineInputBorder()), + decoration: InputDecoration( + labelText: context.l10n.labelDescriptionOptional, + border: const OutlineInputBorder(), + ), maxLength: 300, maxLines: 3, textCapitalization: TextCapitalization.sentences, ), if (!_isEditing) ...[ const SizedBox(height: 12), - Text('Type', style: context.textTheme.labelLarge), + Text(context.l10n.labelType, style: context.textTheme.labelLarge), const SizedBox(height: 8), SegmentedButton( - segments: const [ + segments: [ ButtonSegment( value: KnownListPurpose.appBskyGraphDefsCuratelist, - label: Text('Feed'), - icon: Icon(Icons.dynamic_feed_outlined), + label: Text(context.l10n.labelFeed), + icon: const Icon(Icons.dynamic_feed_outlined), ), ButtonSegment( value: KnownListPurpose.appBskyGraphDefsModlist, - label: Text('Moderation'), - icon: Icon(Icons.shield_outlined), + label: Text(context.l10n.labelModeration), + icon: const Icon(Icons.shield_outlined), ), ], selected: {_purpose}, @@ -214,10 +218,10 @@ class _CreateEditListDialogState extends State { ), ), actions: [ - TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')), + TextButton(onPressed: () => Navigator.pop(context), child: Text(context.l10n.buttonCancel)), FilledButton( onPressed: _nameController.text.trim().isEmpty ? null : _save, - child: Text(_isEditing ? 'Save' : 'Create'), + child: Text(_isEditing ? context.l10n.buttonSave : context.l10n.buttonCreate), ), ], ); diff --git a/lib/features/lists/presentation/widgets/list_row_tile.dart b/lib/features/lists/presentation/widgets/list_row_tile.dart index 9809eba..6adabc4 100644 --- a/lib/features/lists/presentation/widgets/list_row_tile.dart +++ b/lib/features/lists/presentation/widgets/list_row_tile.dart @@ -1,5 +1,6 @@ import 'package:bluesky/app_bsky_graph_defs.dart' as bsky_graph; import 'package:flutter/material.dart'; +import 'package:lazurite/core/l10n/l10n.dart'; import 'package:lazurite/core/theme/theme_extensions.dart'; import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; @@ -15,7 +16,7 @@ class ListRowTile extends StatelessWidget { Widget build(BuildContext context) { final colorScheme = context.colorScheme; final isMod = list.purpose.knownValue == bsky_graph.KnownListPurpose.appBskyGraphDefsModlist; - final purposeLabel = isMod ? 'MOD' : 'FEED'; + final purposeLabel = isMod ? context.l10n.labelModerationShort : context.l10n.labelFeed.toUpperCase(); final purposeColor = isMod ? colorScheme.error : colorScheme.primary; return ListTile( @@ -27,7 +28,10 @@ class ListRowTile extends StatelessWidget { fallbackBuilder: (_) => Icon(Icons.list, color: colorScheme.onSurfaceVariant), ), title: Text(list.name, maxLines: 1, overflow: TextOverflow.ellipsis), - subtitle: Text('${list.listItemCount ?? 0} members', style: TextStyle(color: colorScheme.onSurfaceVariant)), + subtitle: Text( + context.l10n.formatMemberCount(list.listItemCount ?? 0), + style: TextStyle(color: colorScheme.onSurfaceVariant), + ), trailing: trailing ?? Container( diff --git a/lib/features/logs/presentation/logs_screen.dart b/lib/features/logs/presentation/logs_screen.dart index b77d936..f3cdf9a 100644 --- a/lib/features/logs/presentation/logs_screen.dart +++ b/lib/features/logs/presentation/logs_screen.dart @@ -1,14 +1,15 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:logger/logger.dart'; +import 'package:lazurite/core/l10n/l10n.dart'; import 'package:lazurite/core/logging/app_logger.dart'; +import 'package:lazurite/core/theme/theme_extensions.dart'; import 'package:lazurite/features/logs/cubit/log_viewer_cubit.dart'; import 'package:lazurite/features/logs/data/log_entry.dart'; import 'package:lazurite/shared/presentation/helpers/share_helper.dart'; import 'package:lazurite/shared/presentation/widgets/empty_state.dart'; import 'package:lazurite/shared/presentation/widgets/error_state.dart'; import 'package:lazurite/shared/presentation/widgets/loading_state.dart'; -import 'package:lazurite/core/theme/theme_extensions.dart'; class LogsScreen extends StatelessWidget { const LogsScreen({super.key}); @@ -71,6 +72,8 @@ class _LogsScreenContentState extends State<_LogsScreenContent> { @override Widget build(BuildContext context) { + final l10n = context.l10n; + return BlocListener( listenWhen: (previous, current) => previous.filteredEntries != current.filteredEntries || previous.status != current.status, @@ -83,12 +86,16 @@ class _LogsScreenContentState extends State<_LogsScreenContent> { }, child: Scaffold( appBar: AppBar( - title: const Text('Logs'), + title: Text(l10n.labelLogs), actions: [ - IconButton(icon: const Icon(Icons.share_outlined), tooltip: 'Share log file', onPressed: _shareLogs), + IconButton( + icon: const Icon(Icons.share_outlined), + tooltip: l10n.tooltipShareLogFile, + onPressed: _shareLogs, + ), IconButton( icon: Icon(Icons.delete_outline, color: context.colorScheme.error), - tooltip: 'Clear all logs', + tooltip: l10n.tooltipClearAllLogs, onPressed: () => _confirmClearLogs(context), ), ], @@ -115,6 +122,7 @@ class _LogsScreenContentState extends State<_LogsScreenContent> { final cubit = context.read(); final messenger = ScaffoldMessenger.of(context); final shareOrigin = ShareHelper.sharePositionOriginForContext(context); + final l10n = context.l10n; final file = await cubit.getTodaysLogFile(); if (!mounted) { @@ -125,7 +133,7 @@ class _LogsScreenContentState extends State<_LogsScreenContent> { if (!mounted) { return; } - messenger.showSnackBar(const SnackBar(content: Text('No log file available'))); + messenger.showSnackBar(SnackBar(content: Text(l10n.messageNoLogFileAvailable))); return; } @@ -134,13 +142,13 @@ class _LogsScreenContentState extends State<_LogsScreenContent> { } try { - await ShareHelper.shareFilePathsAtOrigin(shareOrigin, [file.path], subject: 'Lazurite logs'); + await ShareHelper.shareFilePathsAtOrigin(shareOrigin, [file.path], subject: l10n.subjectLazuriteLogs); } catch (error, stackTrace) { log.e('LogsScreen: Failed to open share sheet for log file', error: error, stackTrace: stackTrace); if (!mounted) { return; } - messenger.showSnackBar(const SnackBar(content: Text('Unable to open share sheet. Please try again.'))); + messenger.showSnackBar(SnackBar(content: Text(l10n.messageUnableToOpenShareSheet))); } } @@ -148,13 +156,13 @@ class _LogsScreenContentState extends State<_LogsScreenContent> { final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( - title: const Text('Clear all logs?'), - content: const Text('This will permanently delete all log files. This action cannot be undone.'), + title: Text(context.l10n.dialogClearAllLogsTitle), + content: Text(context.l10n.dialogClearAllLogsContent), actions: [ - TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')), + TextButton(onPressed: () => Navigator.pop(context, false), child: Text(context.l10n.buttonCancel)), TextButton( onPressed: () => Navigator.pop(context, true), - child: Text('Clear', style: TextStyle(color: context.colorScheme.error)), + child: Text(context.l10n.buttonClear, style: TextStyle(color: context.colorScheme.error)), ), ], ), @@ -196,7 +204,7 @@ class _AutoScrollIndicator extends StatelessWidget { ), const SizedBox(width: 4), Text( - 'Auto-scroll', + context.l10n.labelAutoScroll, style: TextStyle(fontSize: 12, color: isActive ? colorScheme.primary : colorScheme.outline), ), ], @@ -214,7 +222,7 @@ class _SearchBar extends StatelessWidget { padding: const EdgeInsets.all(12), child: TextField( decoration: InputDecoration( - hintText: 'Filter logs...', + hintText: context.l10n.placeholderLogsFilter, prefixIcon: const Icon(Icons.search, size: 20), border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), @@ -229,12 +237,12 @@ class _SearchBar extends StatelessWidget { class _LevelFilterChips extends StatelessWidget { static const _levels = [ - (Level.fatal, 'Fatal', Colors.orange), - (Level.error, 'Error', Colors.red), - (Level.warning, 'Warning', Colors.yellow), - (Level.info, 'Info', Colors.blue), - (Level.debug, 'Debug', Colors.green), - (Level.trace, 'Trace', Colors.purple), + (Level.fatal, Colors.orange), + (Level.error, Colors.red), + (Level.warning, Colors.yellow), + (Level.info, Colors.blue), + (Level.debug, Colors.green), + (Level.trace, Colors.purple), ]; @override @@ -250,10 +258,10 @@ class _LevelFilterChips extends StatelessWidget { itemCount: _levels.length, separatorBuilder: (context, index) => const SizedBox(width: 6), itemBuilder: (context, index) { - final (level, label, color) = _levels[index]; + final (level, color) = _levels[index]; final isEnabled = state.enabledLevels.contains(level); return FilterChip( - label: Text(label), + label: Text(_labelForLevel(context, level)), selected: isEnabled, onSelected: (selected) => context.read().toggleLevel(level), avatar: Container( @@ -294,16 +302,16 @@ class _LogList extends StatelessWidget { if (state.status == LogViewerStatus.error) { return ErrorState( - title: 'Failed to load logs', - message: state.errorMessage ?? 'Unknown error', + title: context.l10n.errorFailedToLoadLogs, + message: state.errorMessage ?? context.l10n.errorUnknown, onRetry: () => context.read().loadLogs(), ); } if (state.filteredEntries.isEmpty) { - return const EmptyState( - message: 'No logs yet', - subtitle: 'Log entries will appear here', + return EmptyState( + message: context.l10n.messageLogsEmpty, + subtitle: context.l10n.messageLogsEmptySubtitle, icon: Icons.description_outlined, ); } @@ -321,6 +329,19 @@ class _LogList extends StatelessWidget { } } +String _labelForLevel(BuildContext context, Level level) { + final l10n = context.l10n; + return switch (level) { + Level.fatal => l10n.labelLogLevelFatal, + Level.error => l10n.labelLogLevelError, + Level.warning => l10n.labelLogLevelWarning, + Level.info => l10n.labelLogLevelInfo, + Level.debug => l10n.labelLogLevelDebug, + Level.trace => l10n.labelLogLevelTrace, + _ => level.name, + }; +} + class _LogEntryTile extends StatefulWidget { const _LogEntryTile({required this.entry}); diff --git a/lib/features/messages/presentation/convo_list_screen.dart b/lib/features/messages/presentation/convo_list_screen.dart index 72c0a22..5a1eb85 100644 --- a/lib/features/messages/presentation/convo_list_screen.dart +++ b/lib/features/messages/presentation/convo_list_screen.dart @@ -1,9 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lazurite/core/l10n/l10n.dart'; import 'package:lazurite/core/router/app_shell.dart'; +import 'package:lazurite/core/theme/theme_extensions.dart'; import 'package:lazurite/features/messages/bloc/convo_list_bloc.dart'; import 'package:lazurite/features/messages/presentation/widgets/convo_list_pane.dart'; -import 'package:lazurite/core/theme/theme_extensions.dart'; class ConvoListScreen extends StatefulWidget { const ConvoListScreen({super.key}); @@ -41,12 +42,12 @@ class _ConvoListScreenState extends State with SingleTickerProv return Scaffold( appBar: AppBar( leading: const AppShellMenuButton(), - title: Text('Messages', style: context.textTheme.titleMedium), + title: Text(context.l10n.labelMessages, style: context.textTheme.titleMedium), bottom: TabBar( controller: _tabController, - tabs: const [ - Tab(text: 'Primary'), - Tab(text: 'Requests'), + tabs: [ + Tab(text: context.l10n.labelPrimary), + Tab(text: context.l10n.labelMessageRequests), ], ), ), diff --git a/lib/features/messages/presentation/message_thread_screen.dart b/lib/features/messages/presentation/message_thread_screen.dart index bce9ab8..9c79ce9 100644 --- a/lib/features/messages/presentation/message_thread_screen.dart +++ b/lib/features/messages/presentation/message_thread_screen.dart @@ -2,6 +2,7 @@ import 'package:bluesky/chat_bsky_convo_getmessages.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lazurite/core/l10n/l10n.dart'; import 'package:lazurite/features/messages/bloc/convo_list_bloc.dart'; import 'package:lazurite/features/messages/bloc/message_bloc.dart'; import 'package:lazurite/features/messages/presentation/widgets/message_bubble.dart'; @@ -66,7 +67,7 @@ class _MessageThreadScreenState extends State { Clipboard.setData(ClipboardData(text: lines)); ScaffoldMessenger.of( context, - ).showSnackBar(const SnackBar(content: Text('Thread copied'), duration: Duration(seconds: 2))); + ).showSnackBar(SnackBar(content: Text(context.l10n.messageThreadCopied), duration: const Duration(seconds: 2))); } ThemeData get _theme => Theme.of(context); @@ -86,7 +87,9 @@ class _MessageThreadScreenState extends State { _copyAllMessages(state.messages); } }, - itemBuilder: (_) => const [PopupMenuItem(value: _ThreadAction.copyAll, child: Text('Copy All'))], + itemBuilder: (_) => [ + PopupMenuItem(value: _ThreadAction.copyAll, child: Text(context.l10n.buttonCopyAll)), + ], ), ), ], @@ -106,7 +109,7 @@ class _MessageThreadScreenState extends State { } if (state.messages.isEmpty) { - return Center(child: Text('No messages yet', style: _theme.textTheme.bodyLarge)); + return Center(child: Text(context.l10n.messageNoMessagesYet, style: _theme.textTheme.bodyLarge)); } return ListView.builder( @@ -157,7 +160,7 @@ class _MessageThreadScreenState extends State { maxLines: 5, textCapitalization: TextCapitalization.sentences, decoration: InputDecoration( - hintText: 'Message…', + hintText: context.l10n.messagePlaceholder, border: OutlineInputBorder(borderRadius: BorderRadius.circular(24)), contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), isDense: true, @@ -183,11 +186,11 @@ class _MessageThreadScreenState extends State { child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - Text('Failed to load messages', style: _theme.textTheme.titleMedium), + Text(context.l10n.errorFailedToLoadMessages, style: _theme.textTheme.titleMedium), const SizedBox(height: 16), FilledButton( onPressed: () => context.read().add(MessagesRequested(convoId: widget.convoId)), - child: const Text('Retry'), + child: Text(context.l10n.buttonRetry), ), ], ), diff --git a/lib/features/messages/presentation/widgets/convo_list_item.dart b/lib/features/messages/presentation/widgets/convo_list_item.dart index b53c027..4d74ec3 100644 --- a/lib/features/messages/presentation/widgets/convo_list_item.dart +++ b/lib/features/messages/presentation/widgets/convo_list_item.dart @@ -1,5 +1,6 @@ import 'package:bluesky/chat_bsky_convo_defs.dart'; import 'package:flutter/material.dart'; +import 'package:lazurite/core/l10n/l10n.dart'; import 'package:lazurite/core/theme/theme_extensions.dart'; import 'package:lazurite/shared/presentation/widgets/actor_name_widget.dart'; import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; @@ -23,9 +24,9 @@ class ConvoListItem extends StatelessWidget { final theme = Theme.of(context); final other = convo.members.where((m) => m.did != currentUserDid).firstOrNull; final displayName = other?.displayName; - final handle = other?.handle ?? 'unknown'; + final handle = other?.handle ?? context.l10n.commonUnknown; final fallbackName = displayName ?? handle; - final lastMessageText = _lastMessageText(); + final lastMessageText = _lastMessageText(context); return ListTile( onTap: onTap, @@ -78,7 +79,7 @@ class ConvoListItem extends StatelessWidget { itemBuilder: (_) => [ PopupMenuItem( value: convo.muted ? _ConvoAction.unmute : _ConvoAction.mute, - child: Text(convo.muted ? 'Unmute' : 'Mute'), + child: Text(convo.muted ? context.l10n.buttonUnmute : context.l10n.buttonMute), ), ], child: Icon(Icons.more_vert, color: theme.colorScheme.onSurfaceVariant), @@ -95,13 +96,13 @@ class ConvoListItem extends StatelessWidget { ); } - String? _lastMessageText() { + String? _lastMessageText(BuildContext context) { final lastMessage = convo.lastMessage; if (lastMessage == null) return null; return lastMessage.when( messageView: (data) => data.text.isNotEmpty ? data.text : null, - deletedMessageView: (_) => 'Message deleted', + deletedMessageView: (_) => context.l10n.messageDeleted, unknown: (_) => null, ); } diff --git a/lib/features/messages/presentation/widgets/convo_list_pane.dart b/lib/features/messages/presentation/widgets/convo_list_pane.dart index f1c8130..4856f7e 100644 --- a/lib/features/messages/presentation/widgets/convo_list_pane.dart +++ b/lib/features/messages/presentation/widgets/convo_list_pane.dart @@ -2,7 +2,9 @@ import 'package:bluesky/chat_bsky_convo_defs.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import 'package:lazurite/core/l10n/l10n.dart'; import 'package:lazurite/core/logging/app_logger.dart'; +import 'package:lazurite/core/theme/theme_extensions.dart'; import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; import 'package:lazurite/features/messages/bloc/convo_list_bloc.dart'; import 'package:lazurite/features/messages/presentation/message_thread_route_args.dart'; @@ -12,7 +14,6 @@ import 'package:lazurite/shared/presentation/widgets/empty_state.dart'; import 'package:lazurite/shared/presentation/widgets/error_state.dart'; import 'package:lazurite/shared/presentation/widgets/loading_state.dart'; import 'package:lazurite/shared/presentation/widgets/staggered_entrance.dart'; -import 'package:lazurite/core/theme/theme_extensions.dart'; class ConvoListPane extends StatefulWidget { const ConvoListPane({super.key, required this.tab}); @@ -93,8 +94,8 @@ class _ConvoListPaneState extends State { return const _OfflineConvoState(); } return ErrorState( - title: 'Failed to load messages', - message: state.errorMessage ?? 'Unknown error', + title: context.l10n.errorFailedToLoadMessages, + message: state.errorMessage ?? context.l10n.errorUnknown, onRetry: () => context.read().add(const ConvosRequested()), ); } @@ -113,7 +114,9 @@ class _ConvoListPaneState extends State { SizedBox( height: MediaQuery.of(context).size.height * 0.5, child: EmptyState( - message: widget.tab == ConvoTab.primary ? 'No conversations yet' : 'No message requests', + message: widget.tab == ConvoTab.primary + ? context.l10n.messageNoConversationsYet + : context.l10n.messageNoMessageRequests, icon: Icons.forum_outlined, ), ), @@ -162,7 +165,7 @@ class _ConvoListPaneState extends State { void _openThread(BuildContext context, ConvoView convo, String currentUserDid) { final other = convo.members.where((m) => m.did != currentUserDid).firstOrNull; - final title = other?.displayName ?? other?.handle ?? 'Conversation'; + final title = other?.displayName ?? other?.handle ?? context.l10n.labelConversation; context.push('/alerts/messages/${convo.id}', extra: MessageThreadRouteArgs(title: title)); } } @@ -180,9 +183,13 @@ class _OfflineConvoState extends StatelessWidget { children: [ Icon(Icons.cloud_off_outlined, size: 48, color: context.colorScheme.outline), const SizedBox(height: 12), - Text('No connection', style: context.textTheme.titleMedium), + Text(context.l10n.messageNoConnection, style: context.textTheme.titleMedium), const SizedBox(height: 8), - Text('Reconnect to load messages.', textAlign: TextAlign.center, style: context.textTheme.bodyMedium), + Text( + context.l10n.messageReconnectToLoadMessages, + textAlign: TextAlign.center, + style: context.textTheme.bodyMedium, + ), ], ), ), diff --git a/lib/features/messages/presentation/widgets/message_bubble.dart b/lib/features/messages/presentation/widgets/message_bubble.dart index 8c48d93..7871b09 100644 --- a/lib/features/messages/presentation/widgets/message_bubble.dart +++ b/lib/features/messages/presentation/widgets/message_bubble.dart @@ -1,6 +1,7 @@ import 'package:bluesky/chat_bsky_convo_defs.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:lazurite/core/l10n/l10n.dart'; class MessageBubble extends StatelessWidget { const MessageBubble({super.key, required this.message, required this.isCurrentUser}); @@ -48,7 +49,7 @@ class MessageBubble extends StatelessWidget { Clipboard.setData(ClipboardData(text: message.text)); ScaffoldMessenger.of( context, - ).showSnackBar(const SnackBar(content: Text('Message copied'), duration: Duration(seconds: 2))); + ).showSnackBar(SnackBar(content: Text(context.l10n.messageCopied), duration: const Duration(seconds: 2))); } } @@ -77,7 +78,7 @@ class DeletedMessageBubble extends StatelessWidget { ), ), child: Text( - 'Message deleted', + context.l10n.messageDeleted, style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurfaceVariant, fontStyle: FontStyle.italic, diff --git a/lib/features/moderation/presentation/moderation_ui_helpers.dart b/lib/features/moderation/presentation/moderation_ui_helpers.dart index 98c55ce..a7017c4 100644 --- a/lib/features/moderation/presentation/moderation_ui_helpers.dart +++ b/lib/features/moderation/presentation/moderation_ui_helpers.dart @@ -3,6 +3,7 @@ import 'package:bluesky/app_bsky_actor_defs.dart'; import 'package:bluesky/moderation.dart' as bsky_moderation; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lazurite/core/l10n/app_localizations.dart'; import 'package:lazurite/features/moderation/data/moderation_service.dart'; const officialBlueskyLabelerDid = 'did:plc:ar7c4by46qjdydhdevvrndac'; @@ -74,13 +75,14 @@ String formatLocalizedLabelDescription( List moderationBadgesForUi( bsky_moderation.ModerationUI ui, { ModerationLabelResolver? labelResolver, + AppLocalizations? l10n, }) { final badges = []; final seen = {}; void addDescriptors(List causes, ModerationBadgeTone tone) { for (final cause in causes) { - final descriptor = moderationDescriptorForCause(cause, tone: tone, labelResolver: labelResolver); + final descriptor = moderationDescriptorForCause(cause, tone: tone, labelResolver: labelResolver, l10n: l10n); final key = '${tone.name}:${descriptor.label}:${descriptor.description}'; if (seen.add(key)) { badges.add(descriptor); @@ -93,7 +95,11 @@ List moderationBadgesForUi( return badges; } -List moderationBlurLabels(bsky_moderation.ModerationUI ui, {ModerationLabelResolver? labelResolver}) { +List moderationBlurLabels( + bsky_moderation.ModerationUI ui, { + ModerationLabelResolver? labelResolver, + AppLocalizations? l10n, +}) { final labels = []; final seen = {}; @@ -102,6 +108,7 @@ List moderationBlurLabels(bsky_moderation.ModerationUI ui, {ModerationLa cause, tone: ModerationBadgeTone.alert, labelResolver: labelResolver, + l10n: l10n, ); if (seen.add(descriptor.label)) { labels.add(descriptor.label); @@ -115,6 +122,7 @@ ModerationBadgeDescriptor moderationDescriptorForCause( bsky_moderation.ModerationCause cause, { required ModerationBadgeTone tone, ModerationLabelResolver? labelResolver, + AppLocalizations? l10n, }) { return cause.maybeWhen( label: (data) { @@ -125,36 +133,50 @@ ModerationBadgeDescriptor moderationDescriptorForCause( final label = (resolvedLabel == null || resolvedLabel.isEmpty) ? humanizeModerationLabel(data.labelDef.identifier) : resolvedLabel; - final source = data.labelDef.definedBy == officialBlueskyLabelerDid ? 'Bluesky' : 'Subscribed labeler'; - return ModerationBadgeDescriptor(label: label, description: '$source label', tone: tone); + final source = data.labelDef.definedBy == officialBlueskyLabelerDid + ? (l10n?.labelModerationSourceBluesky ?? 'Bluesky') + : (l10n?.labelModerationSourceSubscribedLabeler ?? 'Subscribed labeler'); + return ModerationBadgeDescriptor( + label: label, + description: l10n?.formatModerationSourceLabel(source) ?? '$source label', + tone: tone, + ); }, muted: (_) => ModerationBadgeDescriptor( - label: 'Muted account', - description: 'Muted content is being downranked here', + label: l10n?.labelMutedAccount ?? 'Muted account', + description: l10n?.messageMutedAccountDescription ?? 'Muted content is being downranked here', tone: tone, ), muteWord: (_) => ModerationBadgeDescriptor( - label: 'Muted phrase', - description: 'A muted phrase matched this content', + label: l10n?.labelMutedPhrase ?? 'Muted phrase', + description: l10n?.messageMutedPhraseDescription ?? 'A muted phrase matched this content', + tone: tone, + ), + blocking: (_) => ModerationBadgeDescriptor( + label: l10n?.labelBlockedAccount ?? 'Blocked account', + description: l10n?.messageBlockedAccountDescription ?? 'This account is blocked', + tone: tone, + ), + blockedBy: (_) => ModerationBadgeDescriptor( + label: l10n?.labelBlockedByAccount ?? 'Blocked by account', + description: l10n?.messageBlockedByAccountDescription ?? 'This account has blocked you', tone: tone, ), - blocking: (_) => - ModerationBadgeDescriptor(label: 'Blocked account', description: 'This account is blocked', tone: tone), - blockedBy: (_) => - ModerationBadgeDescriptor(label: 'Blocked by account', description: 'This account has blocked you', tone: tone), blockOther: (_) => ModerationBadgeDescriptor( - label: 'Blocked relationship', - description: 'This content is limited by a block relationship', + label: l10n?.labelBlockedRelationship ?? 'Blocked relationship', + description: l10n?.messageBlockedRelationshipDescription ?? 'This content is limited by a block relationship', tone: tone, ), hidden: (_) => ModerationBadgeDescriptor( - label: 'Hidden content', - description: 'This content is hidden by moderation rules', + label: l10n?.labelHiddenContent ?? 'Hidden content', + description: l10n?.messageHiddenContentDescription ?? 'This content is hidden by moderation rules', tone: tone, ), orElse: () => ModerationBadgeDescriptor( - label: tone == ModerationBadgeTone.alert ? 'Sensitive content' : 'Moderation note', - description: 'Moderation guidance applies here', + label: tone == ModerationBadgeTone.alert + ? (l10n?.labelSensitiveContent ?? 'Sensitive content') + : (l10n?.labelModerationNote ?? 'Moderation note'), + description: l10n?.messageModerationGuidanceApplies ?? 'Moderation guidance applies here', tone: tone, ), ); @@ -164,8 +186,9 @@ String moderationOverlayTitle( bsky_moderation.ModerationUI ui, { String fallback = 'Sensitive content', ModerationLabelResolver? labelResolver, + AppLocalizations? l10n, }) { - final labels = moderationBlurLabels(ui, labelResolver: labelResolver); + final labels = moderationBlurLabels(ui, labelResolver: labelResolver, l10n: l10n); if (labels.isEmpty) { return fallback; } diff --git a/lib/features/moderation/presentation/screens/labeler_detail_screen.dart b/lib/features/moderation/presentation/screens/labeler_detail_screen.dart index ab43474..b11937f 100644 --- a/lib/features/moderation/presentation/screens/labeler_detail_screen.dart +++ b/lib/features/moderation/presentation/screens/labeler_detail_screen.dart @@ -2,6 +2,7 @@ import 'package:bluesky/app_bsky_actor_defs.dart'; import 'package:bluesky/app_bsky_labeler_defs.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lazurite/core/l10n/l10n.dart'; import 'package:lazurite/core/theme/theme_extensions.dart'; import 'package:lazurite/features/moderation/data/moderation_service.dart'; import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; @@ -39,7 +40,7 @@ class _LabelerDetailScreenState extends State { await _service.ensureInitialized(); final details = await _service.getLabelerDetails(widget.did); if (details == null) { - throw Exception('Labeler not found.'); + throw const _LabelerNotFoundException(); } final currentLabelers = _service.currentPrefs?.labelers.map((labeler) => labeler.did).toSet() ?? const {}; @@ -63,7 +64,9 @@ class _LabelerDetailScreenState extends State { _reload(); } catch (error) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('$error'))); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(context.l10n.errorFailedToUpdateLabelerSubscription(error)))); } } finally { if (mounted) { @@ -78,15 +81,19 @@ class _LabelerDetailScreenState extends State { _reload(); } catch (error) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Failed to update preference: $error'))); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(context.l10n.errorFailedToUpdateLabelPreference(error)))); } } } @override Widget build(BuildContext context) { + final l10n = context.l10n; + return Scaffold( - appBar: AppBar(title: const Text('Labeler')), + appBar: AppBar(title: Text(l10n.labelLabeler)), body: FutureBuilder<_LabelerDetailData>( future: _loadFuture, builder: (context, snapshot) { @@ -101,11 +108,11 @@ class _LabelerDetailScreenState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Text('Unable to load labeler', style: context.textTheme.titleMedium), + Text(l10n.errorUnableToLoadLabeler, style: context.textTheme.titleMedium), const SizedBox(height: 8), - Text('${snapshot.error}', textAlign: TextAlign.center), + Text(_errorMessageFor(context, snapshot.error), textAlign: TextAlign.center), const SizedBox(height: 16), - FilledButton(onPressed: _reload, child: const Text('Retry')), + FilledButton(onPressed: _reload, child: Text(l10n.buttonRetry)), ], ), ), @@ -171,20 +178,18 @@ class _LabelerDetailScreenState extends State { spacing: 8, runSpacing: 8, children: [ - _PolicyChip(label: '${definitions.length} custom labels'), - _PolicyChip(label: '${labeler.policies.labelValues.length} published values'), - if (isOfficial) const _PolicyChip(label: 'Built-in moderation'), + _PolicyChip(label: l10n.formatCustomLabelCount(definitions.length)), + _PolicyChip(label: l10n.formatPublishedValueCount(labeler.policies.labelValues.length)), + if (isOfficial) _PolicyChip(label: l10n.labelBuiltInModeration), ], ), const SizedBox(height: 16), SwitchListTile.adaptive( value: data.isSubscribed || isOfficial, onChanged: isOfficial || _isUpdatingSubscription ? null : _toggleSubscription, - title: Text(isOfficial ? 'Built-in moderation' : 'Subscribed'), + title: Text(isOfficial ? l10n.labelBuiltInModeration : l10n.labelSubscribed), subtitle: Text( - isOfficial - ? 'This labeler is always active.' - : 'Subscribed labelers are added to your moderation headers and preferences.', + isOfficial ? l10n.messageBuiltInLabelerAlwaysActive : l10n.messageSubscribedLabelersHeaders, ), contentPadding: EdgeInsets.zero, ), @@ -193,7 +198,7 @@ class _LabelerDetailScreenState extends State { ), const SizedBox(height: 24), Text( - 'Published policies'.toUpperCase(), + l10n.labelPublishedPolicies.toUpperCase(), style: context.textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w700, letterSpacing: 0.8), ), const SizedBox(height: 8), @@ -212,15 +217,15 @@ class _LabelerDetailScreenState extends State { ), const SizedBox(height: 24), Text( - 'Label preferences'.toUpperCase(), + l10n.labelLabelPreferences.toUpperCase(), style: context.textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w700, letterSpacing: 0.8), ), const SizedBox(height: 8), if (definitions.isEmpty) - const _PreferenceCard( + _PreferenceCard( child: ListTile( - title: Text('No custom label definitions'), - subtitle: Text('This labeler publishes values, but not localized custom definitions.'), + title: Text(l10n.labelNoCustomLabelDefinitions), + subtitle: Text(l10n.messageNoCustomLabelDefinitions), ), ) else @@ -240,7 +245,7 @@ class _LabelerDetailScreenState extends State { formatLocalizedLabelDescription( definition.locales, locale, - fallback: 'No description available for this label.', + fallback: l10n.messageNoLabelDescriptionAvailable, ), style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceVariant), ), @@ -249,21 +254,32 @@ class _LabelerDetailScreenState extends State { spacing: 8, runSpacing: 8, children: [ - _PolicyChip(label: 'ID ${definition.identifier}'), - _PolicyChip(label: 'Blur ${definition.blurs.toJson()}'), - _PolicyChip(label: 'Severity ${definition.severity.toJson()}'), + _PolicyChip(label: l10n.formatPolicyId(definition.identifier)), + _PolicyChip(label: l10n.formatPolicyBlur(definition.blurs.toJson())), + _PolicyChip(label: l10n.formatPolicySeverity(definition.severity.toJson())), _PolicyChip( - label: 'Default ${visibilityFromDefaultSetting(definition.defaultSetting).name}', + label: l10n.formatPolicyDefault( + visibilityFromDefaultSetting(definition.defaultSetting).name, + ), ), - if (definition.adultOnly ?? false) const _PolicyChip(label: '18+'), + if (definition.adultOnly ?? false) _PolicyChip(label: l10n.labelAdultOnlyShort), ], ), const SizedBox(height: 16), SegmentedButton( - segments: const [ - ButtonSegment(value: KnownContentLabelPrefVisibility.ignore, label: Text('Ignore')), - ButtonSegment(value: KnownContentLabelPrefVisibility.warn, label: Text('Warn')), - ButtonSegment(value: KnownContentLabelPrefVisibility.hide, label: Text('Hide')), + segments: [ + ButtonSegment( + value: KnownContentLabelPrefVisibility.ignore, + label: Text(l10n.labelContentPreferenceIgnore), + ), + ButtonSegment( + value: KnownContentLabelPrefVisibility.warn, + label: Text(l10n.labelContentPreferenceWarn), + ), + ButtonSegment( + value: KnownContentLabelPrefVisibility.hide, + label: Text(l10n.labelContentPreferenceHide), + ), ], selected: { resolveLabelPreference( @@ -281,7 +297,7 @@ class _LabelerDetailScreenState extends State { if ((definition.adultOnly ?? false) && !data.adultContentEnabled) ...[ const SizedBox(height: 10), Text( - 'Enable adult content to change this 18+ label.', + l10n.messageEnableAdultContentForLabel, style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceVariant), ), ], @@ -313,6 +329,17 @@ class _LabelerDetailData { final bool isSubscribed; } +class _LabelerNotFoundException implements Exception { + const _LabelerNotFoundException(); +} + +String _errorMessageFor(BuildContext context, Object? error) { + if (error is _LabelerNotFoundException) { + return context.l10n.errorLabelerNotFound; + } + return '$error'; +} + class _PolicyChip extends StatelessWidget { const _PolicyChip({required this.label}); diff --git a/lib/features/moderation/presentation/screens/moderation_settings_screen.dart b/lib/features/moderation/presentation/screens/moderation_settings_screen.dart index 3e34ce6..66b5567 100644 --- a/lib/features/moderation/presentation/screens/moderation_settings_screen.dart +++ b/lib/features/moderation/presentation/screens/moderation_settings_screen.dart @@ -3,13 +3,14 @@ import 'package:bluesky/app_bsky_labeler_getservices.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import 'package:lazurite/core/l10n/l10n.dart'; +import 'package:lazurite/core/theme/theme_extensions.dart'; import 'package:lazurite/features/moderation/data/moderation_service.dart'; import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; import 'package:lazurite/features/moderation/presentation/widgets/moderated_avatar.dart'; import 'package:lazurite/shared/presentation/helpers/snackbar_helper.dart'; import 'package:lazurite/shared/presentation/widgets/confirmation_dialog.dart'; import 'package:lazurite/shared/utils/format_utils.dart'; -import 'package:lazurite/core/theme/theme_extensions.dart'; class ModerationSettingsScreen extends StatefulWidget { const ModerationSettingsScreen({super.key}); @@ -64,7 +65,7 @@ class _ModerationSettingsScreenState extends State { _reload(); } catch (error) { if (mounted) { - showAppSnackBar(context, 'Failed to update adult content: $error', isError: true); + showAppSnackBar(context, context.l10n.errorFailedToUpdateAdultContent(error), isError: true); } } finally { if (mounted) { @@ -79,13 +80,14 @@ class _ModerationSettingsScreenState extends State { _reload(); } catch (error) { if (mounted) { - showAppSnackBar(context, 'Failed to unsubscribe: $error', isError: true); + showAppSnackBar(context, context.l10n.errorFailedToUnsubscribeLabeler(error), isError: true); } } } Future _showAddLabelerDialog() async { final controller = TextEditingController(); + final rootContext = context; String? errorText; await showDialog( @@ -94,11 +96,13 @@ class _ModerationSettingsScreenState extends State { var isSubmitting = false; return StatefulBuilder( - builder: (context, setDialogState) { + builder: (builderContext, setDialogState) { + final l10n = builderContext.l10n; + Future submit() async { final did = controller.text.trim(); if (did.isEmpty) { - setDialogState(() => errorText = 'Enter a labeler DID.'); + setDialogState(() => errorText = l10n.errorLabelerDidRequired); return; } @@ -110,7 +114,7 @@ class _ModerationSettingsScreenState extends State { try { final details = await _service.getLabelerDetails(did); if (details == null) { - setDialogState(() => errorText = 'No labeler found for that DID.'); + setDialogState(() => errorText = l10n.errorNoLabelerFoundForDid); return; } @@ -120,9 +124,9 @@ class _ModerationSettingsScreenState extends State { Navigator.of(dialogContext).pop(); _reload(); - if (context.mounted) { + if (rootContext.mounted) { final name = details.creator.displayName ?? details.creator.handle; - showAppSnackBar(context, 'Subscribed to $name'); + showAppSnackBar(rootContext, rootContext.l10n.formatSubscribedToLabeler(name)); } } } catch (error) { @@ -133,7 +137,7 @@ class _ModerationSettingsScreenState extends State { } return ConfirmationDialog( - title: const Text('Add labeler'), + title: Text(l10n.dialogAddLabelerTitle), content: SizedBox( width: 420, child: Column( @@ -143,22 +147,19 @@ class _ModerationSettingsScreenState extends State { controller: controller, autofocus: true, decoration: InputDecoration( - labelText: 'Labeler DID', - hintText: 'did:plc:examplelabeler', + labelText: l10n.labelLabelerDid, + hintText: l10n.placeholderLabelerDid, errorText: errorText, border: const OutlineInputBorder(), ), onSubmitted: (_) => isSubmitting ? null : submit(), ), const SizedBox(height: 12), - Text( - 'Paste a labeler DID to review and subscribe to its labels.', - style: context.textTheme.bodySmall, - ), + Text(l10n.messageAddLabelerDidHelper, style: builderContext.textTheme.bodySmall), ], ), ), - confirmLabel: isSubmitting ? 'Adding...' : 'Add', + confirmLabel: isSubmitting ? l10n.buttonAdding : l10n.buttonAdd, confirmEnabled: !isSubmitting, onCancel: isSubmitting ? null : () => Navigator.of(dialogContext).pop(), onConfirm: submit, @@ -171,10 +172,12 @@ class _ModerationSettingsScreenState extends State { @override Widget build(BuildContext context) { + final l10n = context.l10n; + return Scaffold( appBar: AppBar( - title: const Text('Moderation'), - actions: [IconButton(tooltip: 'Refresh', onPressed: _reload, icon: const Icon(Icons.refresh))], + title: Text(l10n.labelModeration), + actions: [IconButton(tooltip: l10n.labelRefresh, onPressed: _reload, icon: const Icon(Icons.refresh))], ), body: FutureBuilder<_ModerationSettingsData>( future: _loadFuture, @@ -190,11 +193,11 @@ class _ModerationSettingsScreenState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Text('Failed to load moderation settings', style: context.textTheme.titleMedium), + Text(l10n.errorFailedToLoadModerationSettings, style: context.textTheme.titleMedium), const SizedBox(height: 8), Text('${snapshot.error}', textAlign: TextAlign.center), const SizedBox(height: 16), - FilledButton(onPressed: _reload, child: const Text('Retry')), + FilledButton(onPressed: _reload, child: Text(l10n.buttonRetry)), ], ), ), @@ -209,26 +212,25 @@ class _ModerationSettingsScreenState extends State { child: ListView( padding: const EdgeInsets.all(16), children: [ - const _SettingsHero( - title: 'Labelers and content moderation', - subtitle: - 'Manage adult-content visibility, subscribed labelers, and the rules each labeler applies to posts and profiles.', + _SettingsHero( + title: l10n.labelLabelersAndContentModeration, + subtitle: l10n.messageModerationSettingsHeroSubtitle, ), const SizedBox(height: 16), _SettingsCard( child: SwitchListTile.adaptive( value: data.adultContentEnabled, onChanged: _isUpdatingAdultContent ? null : _toggleAdultContent, - title: const Text('Adult content'), - subtitle: const Text('Required before 18+ label preferences can be changed.'), + title: Text(l10n.labelAdultContentSetting), + subtitle: Text(l10n.messageAdultContentRequiredForLabels), contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), ), ), const SizedBox(height: 24), _SectionHeader( - title: 'Built-in labeler', + title: l10n.labelBuiltInLabeler, trailing: Text( - 'Always on', + l10n.labelAlwaysOn, style: context.textTheme.labelMedium?.copyWith(color: context.colorScheme.primary), ), ), @@ -243,27 +245,27 @@ class _ModerationSettingsScreenState extends State { ), ) else - const _SettingsCard( + _SettingsCard( child: ListTile( - title: Text('Bluesky moderation'), - subtitle: Text('The built-in labeler is active even if its details cannot be loaded right now.'), + title: Text(l10n.labelBlueskyModeration), + subtitle: Text(l10n.messageBuiltInLabelerActiveWhenUnavailable), ), ), const SizedBox(height: 24), _SectionHeader( - title: 'Custom labelers', + title: l10n.labelCustomLabelers, trailing: FilledButton.tonalIcon( onPressed: _showAddLabelerDialog, icon: const Icon(Icons.add), - label: Text('Add (${labelers.length}/20)'), + label: Text(l10n.formatAddLabelerLimit(labelers.length, 20)), ), ), const SizedBox(height: 8), if (labelers.isEmpty) - const _SettingsCard( + _SettingsCard( child: ListTile( - title: Text('No custom labelers'), - subtitle: Text('Add a labeler DID to subscribe and configure its custom labels.'), + title: Text(l10n.labelNoCustomLabelers), + subtitle: Text(l10n.messageNoCustomLabelers), ), ) else @@ -390,6 +392,7 @@ class _LabelerCard extends StatelessWidget { @override Widget build(BuildContext context) { + final l10n = context.l10n; final creator = labeler.creator; final definitions = labeler.policies.labelValueDefinitions ?? const []; @@ -431,7 +434,7 @@ class _LabelerCard extends StatelessWidget { borderRadius: BorderRadius.circular(999), ), child: Text( - 'Built-in', + l10n.labelBuiltIn, style: context.textTheme.labelSmall?.copyWith(fontWeight: FontWeight.w700), ), ), @@ -456,8 +459,8 @@ class _LabelerCard extends StatelessWidget { spacing: 8, runSpacing: 8, children: [ - _MetaChip(label: '${definitions.length} definitions'), - _MetaChip(label: '${labeler.policies.labelValues.length} published values'), + _MetaChip(label: l10n.formatDefinitionCount(definitions.length)), + _MetaChip(label: l10n.formatPublishedValueCount(labeler.policies.labelValues.length)), ], ), ], @@ -468,7 +471,7 @@ class _LabelerCard extends StatelessWidget { children: [ Icon(isSubscribed ? Icons.chevron_right : Icons.add_circle_outline), if (!isOfficial && onUnsubscribe != null) - TextButton(onPressed: onUnsubscribe, child: const Text('Unsubscribe')), + TextButton(onPressed: onUnsubscribe, child: Text(l10n.buttonUnsubscribe)), ], ), ], diff --git a/lib/features/moderation/presentation/widgets/moderated_blur_overlay.dart b/lib/features/moderation/presentation/widgets/moderated_blur_overlay.dart index 9a6c333..02c105c 100644 --- a/lib/features/moderation/presentation/widgets/moderated_blur_overlay.dart +++ b/lib/features/moderation/presentation/widgets/moderated_blur_overlay.dart @@ -1,18 +1,20 @@ import 'dart:ui'; -import 'package:lazurite/core/l10n/l10n.dart'; -import 'package:lazurite/core/theme/theme_extensions.dart'; import 'package:bluesky/moderation.dart' as bsky_moderation; import 'package:flutter/material.dart'; +import 'package:lazurite/core/l10n/l10n.dart'; +import 'package:lazurite/core/theme/theme_extensions.dart'; import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; +const _defaultModerationFallbackLabel = 'Sensitive content'; + class ModeratedBlurOverlay extends StatefulWidget { const ModeratedBlurOverlay({ super.key, required this.ui, required this.child, this.borderRadius, - this.fallbackLabel = 'Sensitive content', + this.fallbackLabel = _defaultModerationFallbackLabel, this.fillWidth = true, this.labelResolver, }); @@ -81,8 +83,11 @@ class _ModeratedBlurOverlayState extends State { Text( moderationOverlayTitle( widget.ui, - fallback: widget.fallbackLabel, + fallback: widget.fallbackLabel == _defaultModerationFallbackLabel + ? l10n.labelSensitiveContent + : widget.fallbackLabel, labelResolver: effectiveResolver, + l10n: l10n, ), textAlign: TextAlign.center, style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w700), diff --git a/lib/features/moderation/presentation/widgets/moderation_badge_row.dart b/lib/features/moderation/presentation/widgets/moderation_badge_row.dart index 3a953bb..9480b42 100644 --- a/lib/features/moderation/presentation/widgets/moderation_badge_row.dart +++ b/lib/features/moderation/presentation/widgets/moderation_badge_row.dart @@ -1,7 +1,8 @@ import 'package:bluesky/moderation.dart' as bsky_moderation; import 'package:flutter/material.dart'; -import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; +import 'package:lazurite/core/l10n/l10n.dart'; import 'package:lazurite/core/theme/theme_extensions.dart'; +import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; class ModerationBadgeRow extends StatelessWidget { const ModerationBadgeRow({super.key, required this.ui, this.padding = EdgeInsets.zero, this.labelResolver}); @@ -12,6 +13,7 @@ class ModerationBadgeRow extends StatelessWidget { @override Widget build(BuildContext context) { + final l10n = context.l10n; final moderationService = maybeModerationService(context); final locale = Localizations.localeOf(context); final effectiveResolver = @@ -24,7 +26,7 @@ class ModerationBadgeRow extends StatelessWidget { preferredLanguages: [locale.toLanguageTag(), locale.languageCode], )); - final badges = moderationBadgesForUi(ui, labelResolver: effectiveResolver); + final badges = moderationBadgesForUi(ui, labelResolver: effectiveResolver, l10n: l10n); if (badges.isEmpty) { return const SizedBox.shrink(); } diff --git a/lib/features/notifications/domain/notification_local_mappers.dart b/lib/features/notifications/domain/notification_local_mappers.dart index 26e13f9..84f544c 100644 --- a/lib/features/notifications/domain/notification_local_mappers.dart +++ b/lib/features/notifications/domain/notification_local_mappers.dart @@ -1,7 +1,9 @@ import 'dart:convert'; +import 'dart:ui'; import 'package:bluesky/app_bsky_notification_listnotifications.dart'; import 'package:crypto/crypto.dart'; +import 'package:lazurite/core/l10n/app_localizations.dart'; import 'package:lazurite/features/notifications/domain/notification_local_models.dart'; import 'package:lazurite/features/notifications/domain/notification_reason_utils.dart'; @@ -39,6 +41,8 @@ class NotificationPayloadCodec { } class NotificationLocalMapper { + static final AppLocalizations _fallbackL10n = lookupAppLocalizations(const Locale('en')); + static LocalNotificationRequest? requestFromNotification(Notification notification) { if (notification.isRead) { return null; @@ -52,7 +56,7 @@ class NotificationLocalMapper { return LocalNotificationRequest( notificationId: _stableNotificationId(notification.uri.toString()), title: _titleForNotification(notification), - body: NotificationReasonUtils.localNotificationBodyForReason(notification.reason), + body: NotificationReasonUtils.localNotificationBodyForReason(notification.reason, l10n: _fallbackL10n), reasonFamily: NotificationReasonUtils.reasonFamilyForReason(notification.reason), deepLink: deepLink, ); @@ -64,7 +68,7 @@ class NotificationLocalMapper { return displayName; } final handle = notification.author.handle.trim(); - return handle.isEmpty ? 'New notification' : handle; + return handle.isEmpty ? _fallbackL10n.messageNewNotification : handle; } static int _stableNotificationId(String value) { @@ -93,15 +97,15 @@ extension NotificationReasonFamilyChannels on NotificationReasonFamily { String get androidChannelName { switch (this) { case NotificationReasonFamily.mentions: - return 'Mentions'; + return NotificationLocalMapper._fallbackL10n.labelMentions; case NotificationReasonFamily.replies: - return 'Replies'; + return NotificationLocalMapper._fallbackL10n.labelReplies; case NotificationReasonFamily.follows: - return 'Follows'; + return NotificationLocalMapper._fallbackL10n.labelFollows; case NotificationReasonFamily.likes: - return 'Likes'; + return NotificationLocalMapper._fallbackL10n.labelLikes; case NotificationReasonFamily.misc: - return 'Other'; + return NotificationLocalMapper._fallbackL10n.labelOther; } } diff --git a/lib/features/notifications/domain/notification_reason_utils.dart b/lib/features/notifications/domain/notification_reason_utils.dart index a99ab3e..ec9b17d 100644 --- a/lib/features/notifications/domain/notification_reason_utils.dart +++ b/lib/features/notifications/domain/notification_reason_utils.dart @@ -1,5 +1,8 @@ import 'package:atproto_core/atproto_core.dart'; import 'package:bluesky/app_bsky_notification_listnotifications.dart' as bsky; +import 'dart:ui'; + +import 'package:lazurite/core/l10n/app_localizations.dart'; import 'package:lazurite/features/notifications/domain/notification_local_models.dart'; abstract final class NotificationReasonUtils { @@ -35,46 +38,50 @@ abstract final class NotificationReasonUtils { } } - static String summaryTextForReason(bsky.NotificationReason reason) { + static final AppLocalizations _fallbackL10n = lookupAppLocalizations(const Locale('en')); + + static String summaryTextForReason(bsky.NotificationReason reason, {AppLocalizations? l10n}) { + final strings = l10n ?? _fallbackL10n; if (!reason.isKnownValue) { - return 'interacted with you'; + return strings.messageNotificationInteracted; } switch (reason.knownValue) { case bsky.KnownNotificationReason.like: - return 'liked your post'; + return strings.messageNotificationLike; case bsky.KnownNotificationReason.repost: - return 'reposted your post'; + return strings.messageNotificationRepost; case bsky.KnownNotificationReason.likeViaRepost: - return 'liked your repost'; + return strings.messageNotificationLikeViaRepost; case bsky.KnownNotificationReason.repostViaRepost: - return 'reposted your repost'; + return strings.messageNotificationRepostViaRepost; case bsky.KnownNotificationReason.follow: - return 'followed you'; + return strings.messageNotificationFollow; case bsky.KnownNotificationReason.mention: - return 'mentioned you'; + return strings.messageNotificationMention; case bsky.KnownNotificationReason.reply: - return 'replied to your post'; + return strings.messageNotificationReply; case bsky.KnownNotificationReason.quote: - return 'quoted your post'; + return strings.messageNotificationQuote; case bsky.KnownNotificationReason.starterpackJoined: - return 'joined via your starter pack'; + return strings.messageNotificationStarterPackJoined; case bsky.KnownNotificationReason.verified: - return 'verified your account'; + return strings.messageNotificationVerified; case bsky.KnownNotificationReason.unverified: - return 'removed your verification'; + return strings.messageNotificationUnverified; case bsky.KnownNotificationReason.subscribedPost: - return 'posted a new update'; + return strings.messageNotificationSubscribedPost; case bsky.KnownNotificationReason.contactMatch: - return 'joined from your contacts'; + return strings.messageNotificationContactMatch; default: - return 'interacted with you'; + return strings.messageNotificationInteracted; } } - static String localNotificationBodyForReason(bsky.NotificationReason reason) { - final summary = summaryTextForReason(reason); - return summary == 'interacted with you' ? 'sent a notification' : summary; + static String localNotificationBodyForReason(bsky.NotificationReason reason, {AppLocalizations? l10n}) { + final strings = l10n ?? _fallbackL10n; + final summary = summaryTextForReason(reason, l10n: strings); + return summary == strings.messageNotificationInteracted ? strings.messageLocalNotificationFallbackBody : summary; } static NotificationReasonFamily reasonFamilyForReason(bsky.NotificationReason reason) { diff --git a/lib/features/notifications/presentation/notifications_screen.dart b/lib/features/notifications/presentation/notifications_screen.dart index 3858597..be42a04 100644 --- a/lib/features/notifications/presentation/notifications_screen.dart +++ b/lib/features/notifications/presentation/notifications_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lazurite/core/l10n/l10n.dart'; import 'package:lazurite/core/widgets/lazurite_app_bar.dart'; import 'package:lazurite/features/notifications/bloc/notification_bloc.dart'; import 'package:lazurite/features/notifications/cubit/unread_count_cubit.dart'; @@ -12,8 +13,8 @@ class NotificationsScreen extends StatelessWidget { Widget build(BuildContext context) { return Scaffold( appBar: LazuriteAppBar( - sectionLabel: 'Alerts', - actions: [TextButton(onPressed: () => _markAllRead(context), child: const Text('Mark All Read'))], + sectionLabel: context.l10n.labelAlertsTitle, + actions: [TextButton(onPressed: () => _markAllRead(context), child: Text(context.l10n.buttonMarkAllRead))], ), body: const NotificationsPane(), ); diff --git a/lib/features/notifications/presentation/widgets/grouped_notification_list_item.dart b/lib/features/notifications/presentation/widgets/grouped_notification_list_item.dart index 2e442aa..065034e 100644 --- a/lib/features/notifications/presentation/widgets/grouped_notification_list_item.dart +++ b/lib/features/notifications/presentation/widgets/grouped_notification_list_item.dart @@ -3,6 +3,8 @@ import 'package:bluesky/app_bsky_notification_listnotifications.dart' as bsky; import 'package:bluesky/moderation.dart' as bsky_moderation; import 'package:flutter/material.dart' hide Notification; import 'package:go_router/go_router.dart'; +import 'package:lazurite/core/l10n/l10n.dart'; +import 'package:lazurite/core/theme/theme_extensions.dart'; import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; import 'package:lazurite/features/moderation/presentation/widgets/moderated_blur_overlay.dart'; import 'package:lazurite/features/notifications/domain/notification_deep_link_navigator.dart'; @@ -10,7 +12,6 @@ import 'package:lazurite/features/notifications/domain/notification_reason_utils import 'package:lazurite/shared/presentation/helpers/notification_icon_mapper.dart'; import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; import 'package:lazurite/shared/utils/format_utils.dart'; -import 'package:lazurite/core/theme/theme_extensions.dart'; class NotificationGroup { const NotificationGroup({required this.notifications}); @@ -66,9 +67,9 @@ class GroupedNotificationListItem extends StatelessWidget { children: [ _buildActorRow(context), const SizedBox(height: 4), - _buildSummary(theme), + _buildSummary(context, theme), const SizedBox(height: 2), - _buildTime(theme), + _buildTime(context, theme), if (_shouldShowPreview(latest)) ...[ const SizedBox(height: 8), _buildPreview(context, theme, latest), @@ -144,19 +145,19 @@ class GroupedNotificationListItem extends StatelessWidget { ); } - Widget _buildSummary(ThemeData theme) { + Widget _buildSummary(BuildContext context, ThemeData theme) { return RichText( text: TextSpan( children: [ TextSpan( - text: _actorSummary(), + text: _actorSummary(context), style: theme.textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.w600, color: theme.colorScheme.onSurface, ), ), TextSpan( - text: ' ${_getReasonText(group.latest)}', + text: ' ${_getReasonText(context, group.latest)}', style: theme.textTheme.bodyMedium?.copyWith(color: theme.colorScheme.onSurfaceVariant), ), ], @@ -164,29 +165,29 @@ class GroupedNotificationListItem extends StatelessWidget { ); } - String _actorSummary() { + String _actorSummary(BuildContext context) { final names = group.authors.map((author) => author.displayName ?? author.handle).toList(); if (names.isEmpty) { - return 'Someone'; + return context.l10n.labelSomeone; } if (names.length == 1) { return names.first; } if (names.length == 2) { - return '${names[0]} and ${names[1]}'; + return context.l10n.formatActorListTwo(names[0], names[1]); } - return '${names[0]}, ${names[1]}, and ${names.length - 2} others'; + return context.l10n.formatActorListWithOthers(names[0], names[1], names.length - 2); } - Widget _buildTime(ThemeData theme) { + Widget _buildTime(BuildContext context, ThemeData theme) { return Text( - formatRelativeTime(group.latest.indexedAt, nowLabel: 'Just now', includeAgo: true), + formatRelativeTime(group.latest.indexedAt, nowLabel: context.l10n.commonJustNow, includeAgo: true), style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), ); } - String _getReasonText(bsky.Notification notification) { - return NotificationReasonUtils.summaryTextForReason(notification.reason); + String _getReasonText(BuildContext context, bsky.Notification notification) { + return NotificationReasonUtils.summaryTextForReason(notification.reason, l10n: context.l10n); } bool _shouldShowPreview(bsky.Notification notification) { diff --git a/lib/features/notifications/presentation/widgets/notification_list_item.dart b/lib/features/notifications/presentation/widgets/notification_list_item.dart index 9e4bfe4..0fa5584 100644 --- a/lib/features/notifications/presentation/widgets/notification_list_item.dart +++ b/lib/features/notifications/presentation/widgets/notification_list_item.dart @@ -2,6 +2,7 @@ import 'package:bluesky/app_bsky_notification_listnotifications.dart' as bsky; import 'package:bluesky/moderation.dart' as bsky_moderation; import 'package:flutter/material.dart' hide Notification; import 'package:go_router/go_router.dart'; +import 'package:lazurite/core/l10n/l10n.dart'; import 'package:lazurite/features/moderation/presentation/moderation_ui_helpers.dart'; import 'package:lazurite/features/moderation/presentation/widgets/moderated_blur_overlay.dart'; import 'package:lazurite/features/moderation/presentation/widgets/moderation_badge_row.dart'; @@ -45,9 +46,9 @@ class NotificationListItem extends StatelessWidget { children: [ _buildActorRow(context), const SizedBox(height: 4), - _buildSummary(theme), + _buildSummary(context, theme), const SizedBox(height: 2), - _buildTime(theme), + _buildTime(context, theme), if (notificationUi.alert || notificationUi.inform) ...[ const SizedBox(height: 8), ModerationBadgeRow(ui: notificationUi), @@ -95,10 +96,10 @@ class NotificationListItem extends StatelessWidget { ); } - Widget _buildSummary(ThemeData theme) { + Widget _buildSummary(BuildContext context, ThemeData theme) { final author = notification.author; final displayName = author.displayName ?? author.handle; - final reasonText = _getReasonText(); + final reasonText = _getReasonText(context); return RichText( text: TextSpan( @@ -119,13 +120,13 @@ class NotificationListItem extends StatelessWidget { ); } - String _getReasonText() { - return NotificationReasonUtils.summaryTextForReason(notification.reason); + String _getReasonText(BuildContext context) { + return NotificationReasonUtils.summaryTextForReason(notification.reason, l10n: context.l10n); } - Widget _buildTime(ThemeData theme) { + Widget _buildTime(BuildContext context, ThemeData theme) { return Text( - formatRelativeTime(notification.indexedAt, nowLabel: 'Just now', includeAgo: true), + formatRelativeTime(notification.indexedAt, nowLabel: context.l10n.commonJustNow, includeAgo: true), style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), ); } diff --git a/lib/features/notifications/presentation/widgets/notifications_pane.dart b/lib/features/notifications/presentation/widgets/notifications_pane.dart index f04b593..a7f461a 100644 --- a/lib/features/notifications/presentation/widgets/notifications_pane.dart +++ b/lib/features/notifications/presentation/widgets/notifications_pane.dart @@ -3,6 +3,9 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:intl/intl.dart'; +import 'package:lazurite/core/l10n/l10n.dart'; +import 'package:lazurite/core/theme/theme_extensions.dart'; import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; import 'package:lazurite/features/notifications/bloc/notification_bloc.dart'; import 'package:lazurite/features/notifications/cubit/unread_count_cubit.dart'; @@ -13,7 +16,6 @@ import 'package:lazurite/shared/presentation/widgets/empty_state.dart'; import 'package:lazurite/shared/presentation/widgets/error_state.dart'; import 'package:lazurite/shared/presentation/widgets/loading_state.dart'; import 'package:lazurite/shared/presentation/widgets/staggered_entrance.dart'; -import 'package:lazurite/core/theme/theme_extensions.dart'; class NotificationsPane extends StatefulWidget { const NotificationsPane({super.key}); @@ -89,8 +91,8 @@ class _NotificationsPaneState extends State { return const _OfflineNotificationsState(); } return ErrorState( - title: 'Failed to load notifications', - message: state.errorMessage ?? 'Unknown error', + title: context.l10n.errorFailedToLoadNotifications, + message: state.errorMessage ?? context.l10n.errorUnknown, onRetry: () => context.read().add(const NotificationsRequested()), ); } @@ -99,7 +101,7 @@ class _NotificationsPaneState extends State { if (isOffline) { return const _OfflineNotificationsState(); } - return const EmptyState(message: 'No notifications yet', icon: Icons.notifications_none_outlined); + return EmptyState(message: context.l10n.messageNoNotificationsYet, icon: Icons.notifications_none_outlined); } final groupedNotifications = _groupNotificationsByDay(state.notifications); @@ -110,7 +112,7 @@ class _NotificationsPaneState extends State { controller: _scrollController, itemCount: _calculateItemCount(groupedNotifications, state), itemBuilder: (context, index) { - final item = _getItemAtIndex(groupedNotifications, index); + final item = _getItemAtIndex(context, groupedNotifications, index); if (item is String) { return _DayHeader(title: item); @@ -185,12 +187,12 @@ class _NotificationsPaneState extends State { return count; } - dynamic _getItemAtIndex(Map> grouped, int index) { + dynamic _getItemAtIndex(BuildContext context, Map> grouped, int index) { int currentIndex = 0; for (final entry in grouped.entries) { if (currentIndex == index) { - return _formatDayHeader(entry.key); + return _formatDayHeader(context, entry.key); } currentIndex++; @@ -205,37 +207,23 @@ class _NotificationsPaneState extends State { return null; } - String _formatDayHeader(DateTime date) { + String _formatDayHeader(BuildContext context, DateTime date) { final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); final yesterday = today.subtract(const Duration(days: 1)); if (date == today) { - return 'Today'; + return context.l10n.messageToday; } else if (date == yesterday) { - return 'Yesterday'; + return context.l10n.messageYesterday; } - return _formatDate(date); + return _formatDate(context, date); } - String _formatDate(DateTime date) { - const months = [ - 'January', - 'February', - 'March', - 'April', - 'May', - 'June', - 'July', - 'August', - 'September', - 'October', - 'November', - 'December', - ]; - - return '${months[date.month - 1]} ${date.day}'; + String _formatDate(BuildContext context, DateTime date) { + final locale = Localizations.localeOf(context).toLanguageTag(); + return DateFormat.MMMMd(locale).format(date); } } @@ -252,9 +240,13 @@ class _OfflineNotificationsState extends StatelessWidget { children: [ Icon(Icons.cloud_off_outlined, size: 48, color: context.colorScheme.outline), const SizedBox(height: 12), - Text('No connection', style: context.textTheme.titleMedium), + Text(context.l10n.messageNoConnection, style: context.textTheme.titleMedium), const SizedBox(height: 8), - Text('Reconnect to load notifications.', textAlign: TextAlign.center, style: context.textTheme.bodyMedium), + Text( + context.l10n.messageReconnectToLoadNotifications, + textAlign: TextAlign.center, + style: context.textTheme.bodyMedium, + ), ], ), ), diff --git a/lib/features/profile/presentation/follow_audit_screen.dart b/lib/features/profile/presentation/follow_audit_screen.dart index c096d3d..7e2450e 100644 --- a/lib/features/profile/presentation/follow_audit_screen.dart +++ b/lib/features/profile/presentation/follow_audit_screen.dart @@ -1,4 +1,5 @@ import 'dart:math' as math; +import 'package:lazurite/core/l10n/l10n.dart'; import 'package:lazurite/core/theme/theme_extensions.dart'; import 'package:flutter/material.dart'; @@ -15,7 +16,7 @@ class FollowAuditScreen extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text('Audit Followers')), + appBar: AppBar(title: Text(context.l10n.labelAuditFollowers)), body: BlocBuilder( builder: (context, state) { final visibleEntries = _visibleEntries(state); @@ -43,21 +44,21 @@ class FollowAuditScreen extends StatelessWidget { child: Align( alignment: Alignment.centerLeft, child: Text( - '${state.failedProfiles} profile(s) could not be loaded.', + context.l10n.formatProfilesFailedToLoad(state.failedProfiles), key: const Key('follow_audit_failed_warning'), style: TextStyle(color: context.colorScheme.tertiary), ), ), ), if (state.status == FollowAuditStatus.error) - _ErrorBanner(message: state.errorMessage ?? 'Failed to complete follow audit.'), + _ErrorBanner(message: state.errorMessage ?? context.l10n.errorFollowAuditFailed), if (state.status == FollowAuditStatus.complete && state.unfollowedCount > 0) Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), child: Align( alignment: Alignment.centerLeft, child: Text( - 'Unfollowed ${state.unfollowedCount} account(s)', + context.l10n.formatUnfollowedAccounts(state.unfollowedCount), key: const Key('follow_audit_complete_message'), style: context.textTheme.titleMedium, ), @@ -147,14 +148,12 @@ class _HeaderCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'AUDIT FOLLOWERS', + context.l10n.labelAuditFollowers.toUpperCase(), style: context.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w700, letterSpacing: 1.1), ), const SizedBox(height: 6), Text( - totalFollows > 0 - ? '$totalFollows follows scanned for problematic accounts' - : 'Scan your follows for deleted, suspended, blocked, and hidden accounts.', + totalFollows > 0 ? context.l10n.formatFollowsScanned(totalFollows) : context.l10n.messageFollowAuditIntro, style: context.textTheme.bodyMedium, ), ], @@ -179,7 +178,7 @@ class _AuditActionButton extends StatelessWidget { key: const Key('follow_audit_scan_button'), onPressed: isBusy ? null : () => context.read().audit(), icon: const Icon(Icons.manage_search_outlined), - label: const Text('Scan'), + label: Text(context.l10n.buttonScan), ); } @@ -187,7 +186,7 @@ class _AuditActionButton extends StatelessWidget { key: const Key('follow_audit_unfollow_button'), onPressed: selectedCount == 0 || isBusy ? null : () => context.read().confirmUnfollow(), icon: const Icon(Icons.person_remove_outlined), - label: Text('Unfollow Selected ($selectedCount)'), + label: Text(context.l10n.buttonUnfollowSelected(selectedCount)), ); } } @@ -208,8 +207,8 @@ class _ProgressSection extends StatelessWidget { final shownTotal = totalFollows > 0 ? totalFollows : math.max(progress, 1); final value = (progress / shownTotal).clamp(0.0, 1.0); final label = status == FollowAuditStatus.fetching - ? 'Fetching follows: $progress/$shownTotal' - : 'Classifying: $progress/$shownTotal'; + ? context.l10n.formatFetchingFollowsProgress(progress, shownTotal) + : context.l10n.formatClassifyingProgress(progress, shownTotal); return Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), @@ -255,7 +254,7 @@ class _ErrorBanner extends StatelessWidget { OutlinedButton( key: const Key('follow_audit_retry_button'), onPressed: () => context.read().audit(), - child: const Text('Retry'), + child: Text(context.l10n.buttonRetry), ), ], ), @@ -305,7 +304,7 @@ class _FilterControls extends StatelessWidget { Padding( padding: const EdgeInsets.fromLTRB(4, 8, 4, 8), child: Text( - 'FILTERS', + context.l10n.labelFilters.toUpperCase(), style: context.textTheme.labelMedium?.copyWith(letterSpacing: 1.0, fontWeight: FontWeight.w700), ), ), @@ -363,7 +362,7 @@ class _FilterTile extends StatelessWidget { children: [ Expanded( child: Text( - _labelForStatus(status).toUpperCase(), + _labelForStatus(context, status).toUpperCase(), maxLines: 1, overflow: TextOverflow.ellipsis, style: context.textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w700), @@ -387,7 +386,9 @@ class _FilterTile extends StatelessWidget { visualDensity: VisualDensity.compact, icon: Icon(isVisible ? Icons.visibility_outlined : Icons.visibility_off_outlined), onPressed: () => context.read().toggleVisibility(status), - tooltip: isVisible ? 'Hide ${_labelForStatus(status)}' : 'Show ${_labelForStatus(status)}', + tooltip: isVisible + ? context.l10n.formatHideStatus(_labelForStatus(context, status)) + : context.l10n.formatShowStatus(_labelForStatus(context, status)), ), Checkbox( key: Key('follow_audit_select_all_${status.name}'), @@ -405,7 +406,7 @@ class _FilterTile extends StatelessWidget { ), Expanded( child: Text( - 'Select All', + context.l10n.labelSelectAll, maxLines: 1, overflow: TextOverflow.ellipsis, style: context.textTheme.bodySmall, @@ -417,27 +418,6 @@ class _FilterTile extends StatelessWidget { ), ); } - - String _labelForStatus(FollowStatus status) { - switch (status) { - case FollowStatus.deleted: - return 'Deleted'; - case FollowStatus.deactivated: - return 'Deactivated'; - case FollowStatus.suspended: - return 'Suspended'; - case FollowStatus.blockedBy: - return 'Blocked by'; - case FollowStatus.blocking: - return 'Blocking'; - case FollowStatus.mutualBlock: - return 'Mutual block'; - case FollowStatus.hidden: - return 'Hidden'; - case FollowStatus.selfFollow: - return 'Self-follow'; - } - } } class _ResultsPanel extends StatefulWidget { @@ -456,16 +436,18 @@ class _ResultsPanelState extends State<_ResultsPanel> { @override Widget build(BuildContext context) { if (widget.state.status == FollowAuditStatus.initial) { - return const Center(child: Text('Tap Scan to audit your follow list.')); + return Center(child: Text(context.l10n.messageFollowAuditStartPrompt)); } if (widget.state.results.isEmpty && (widget.state.status == FollowAuditStatus.ready || widget.state.status == FollowAuditStatus.complete)) { - return const Center(child: Text('No problematic follows found', key: Key('follow_audit_empty_message'))); + return Center( + child: Text(context.l10n.messageNoProblematicFollows, key: const Key('follow_audit_empty_message')), + ); } if (widget.visibleEntries.isEmpty && widget.state.results.isNotEmpty) { - return const Center(child: Text('No results visible for the current filters.')); + return Center(child: Text(context.l10n.messageNoResultsForFilters)); } return ListView.separated( @@ -535,8 +517,8 @@ class _ResultRow extends StatelessWidget { await Clipboard.setData(ClipboardData(text: item.record.subjectDid)); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('DID copied to clipboard'), + SnackBar( + content: Text(context.l10n.formatDidCopied), behavior: SnackBarBehavior.floating, ), ); @@ -556,7 +538,7 @@ class _ResultRow extends StatelessWidget { ), ), const SizedBox(width: 8), - Chip(label: Text(item.statusLabel), visualDensity: VisualDensity.compact), + Chip(label: Text(_labelForStatus(context, item.status)), visualDensity: VisualDensity.compact), ], ), ), @@ -592,9 +574,23 @@ class _SummaryFooter extends StatelessWidget { border: Border(top: BorderSide(color: context.colorScheme.outlineVariant)), ), child: Text( - 'Selected: $selectedCount/$total', + context.l10n.formatSelectedCount(selectedCount, total), style: context.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.w600), ), ); } } + +String _labelForStatus(BuildContext context, FollowStatus status) { + final l10n = context.l10n; + return switch (status) { + FollowStatus.deleted => l10n.statusDeleted, + FollowStatus.deactivated => l10n.statusDeactivated, + FollowStatus.suspended => l10n.statusSuspended, + FollowStatus.blockedBy => l10n.statusBlockedBy, + FollowStatus.blocking => l10n.statusBlocking, + FollowStatus.mutualBlock => l10n.statusMutualBlock, + FollowStatus.hidden => l10n.statusHidden, + FollowStatus.selfFollow => l10n.statusSelfFollow, + }; +} diff --git a/lib/features/profile/presentation/profile_connections_screen.dart b/lib/features/profile/presentation/profile_connections_screen.dart index 44b7434..a20721e 100644 --- a/lib/features/profile/presentation/profile_connections_screen.dart +++ b/lib/features/profile/presentation/profile_connections_screen.dart @@ -1,10 +1,10 @@ import 'package:bluesky/app_bsky_actor_defs.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lazurite/core/l10n/l10n.dart'; import 'package:lazurite/core/logging/app_logger.dart'; import 'package:lazurite/core/theme/spacing.dart'; import 'package:lazurite/core/theme/theme_extensions.dart'; -import 'package:lazurite/features/connectivity/connectivity_helpers.dart'; import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; import 'package:lazurite/features/profile/cubit/profile_action_cubit.dart'; import 'package:lazurite/features/profile/cubit/profile_connections_cubit.dart'; @@ -68,7 +68,7 @@ class _ProfileConnectionsScreenState extends State wit final subtitle = widget.handle?.trim(); return Scaffold( appBar: AppBar( - title: Text(subtitle == null || subtitle.isEmpty ? 'Connections' : '@$subtitle'), + title: Text(subtitle == null || subtitle.isEmpty ? context.l10n.labelConnections : '@$subtitle'), bottom: TabBar( controller: _tabController, onTap: (index) { @@ -77,10 +77,10 @@ class _ProfileConnectionsScreenState extends State wit cubit.loadTab(tab); cubit.ensureSearchForTab(tab); }, - tabs: const [ - Tab(text: 'Following'), - Tab(text: 'Followers'), - Tab(text: 'Mutuals'), + tabs: [ + Tab(text: context.l10n.labelFollowing), + Tab(text: context.l10n.labelFollowers), + Tab(text: context.l10n.labelMutuals), ], ), ), @@ -131,7 +131,7 @@ class _ConnectionsSearchFieldState extends State<_ConnectionsSearchField> { onChanged: (query) => context.read().setSearchQuery(query, widget.activeTab()), textInputAction: TextInputAction.search, decoration: InputDecoration( - hintText: 'Search handle, name, or description', + hintText: context.l10n.messageSearchConnectionsPlaceholder, prefixIcon: const Icon(Icons.search), suffixIcon: BlocBuilder( buildWhen: (previous, current) => previous.searchQuery != current.searchQuery, @@ -140,7 +140,7 @@ class _ConnectionsSearchFieldState extends State<_ConnectionsSearchField> { return const SizedBox.shrink(); } return IconButton( - tooltip: 'Clear search', + tooltip: context.l10n.tooltipClearSearch, icon: const Icon(Icons.close), onPressed: () { _controller.clear(); @@ -170,13 +170,15 @@ class _ConnectionsTabView extends StatelessWidget { builder: (context, state) { final data = state.dataFor(tab); if (data.isLoading && data.profiles.isEmpty) { - return LoadingState(message: 'Loading ${tab.title.toLowerCase()}...'); + return LoadingState( + message: context.l10n.formatConnectionsLoading(_localizedTabTitle(context, tab, lowercase: true)), + ); } if (data.hasError && data.profiles.isEmpty) { return ErrorState( - title: 'Unable to load ${tab.title.toLowerCase()}', - message: data.errorMessage ?? 'Unknown error', + title: context.l10n.errorUnableToLoadConnections(_localizedTabTitle(context, tab, lowercase: true)), + message: data.errorMessage ?? context.l10n.errorUnknown, onRetry: () => context.read().loadTab(tab, force: true), ); } @@ -185,12 +187,15 @@ class _ConnectionsTabView extends StatelessWidget { final isSearchMode = state.searchQuery.isNotEmpty; if (profiles.isEmpty) { if (isSearchMode && data.isSearching) { - return LoadingState(message: 'Searching ${data.searchedCount} accounts...'); + return LoadingState(message: context.l10n.formatConnectionsSearching(data.searchedCount)); } final message = state.searchQuery.isEmpty - ? 'No ${tab.title.toLowerCase()} found' - : 'No ${tab.title.toLowerCase()} match "${state.searchQuery}"'; + ? context.l10n.formatConnectionsNoneFound(_localizedTabTitle(context, tab, lowercase: true)) + : context.l10n.formatConnectionsNoMatches( + _localizedTabTitle(context, tab, lowercase: true), + state.searchQuery, + ); return EmptyState(message: message, icon: Icons.person_search_outlined); } @@ -295,7 +300,9 @@ class _ConnectionProfileTileBody extends StatelessWidget { Widget build(BuildContext context) { final title = profile.displayName?.trim().isNotEmpty == true ? profile.displayName!.trim() : profile.handle; final description = profile.description?.trim(); - final joined = profile.createdAt == null ? null : 'Joined ${_formatJoinedAgo(profile.createdAt!)}'; + final joined = profile.createdAt == null + ? null + : context.l10n.formatJoinedRelative(_formatJoinedAgo(profile.createdAt!)); final metaStyle = context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceVariant); return Material( @@ -362,13 +369,13 @@ class _InlineFollowButton extends StatelessWidget { final effectiveOnPressed = isOffline || isBlockedBy ? null : onPressed; final button = isFollowing - ? OutlinedButton(onPressed: effectiveOnPressed, child: const Text('Following')) - : FilledButton.tonal(onPressed: effectiveOnPressed, child: const Text('Follow')); + ? OutlinedButton(onPressed: effectiveOnPressed, child: Text(context.l10n.buttonFollowing)) + : FilledButton.tonal(onPressed: effectiveOnPressed, child: Text(context.l10n.buttonFollow)); if (!isOffline) { return button; } - return Tooltip(message: offlineActionMessage('change your follow state'), child: button); + return Tooltip(message: context.l10n.formatOfflineReconnectAction('change your follow state'), child: button); } } @@ -380,7 +387,7 @@ class _YouPill extends StatelessWidget { return Chip( visualDensity: VisualDensity.compact, side: BorderSide(color: context.colorScheme.outlineVariant), - label: const Text('You'), + label: Text(context.l10n.labelYou), ); } } @@ -414,7 +421,7 @@ class _LoadMoreFooter extends StatelessWidget { ], OutlinedButton( onPressed: () => context.read().loadMore(tab), - child: Text(errorMessage == null ? 'Load more' : 'Retry'), + child: Text(errorMessage == null ? context.l10n.buttonLoadMore : context.l10n.buttonRetry), ), ], ), @@ -433,9 +440,9 @@ class _SearchProgressFooter extends StatelessWidget { final colorScheme = context.colorScheme; final textTheme = context.textTheme; final message = switch (data.searchStatus) { - ProfileConnectionsSearchStatus.searching => 'Searching ${data.searchedCount} accounts...', - ProfileConnectionsSearchStatus.complete => 'Searched ${data.searchedCount} accounts', - ProfileConnectionsSearchStatus.error => 'Search stopped after ${data.searchedCount} accounts', + ProfileConnectionsSearchStatus.searching => context.l10n.formatConnectionsSearching(data.searchedCount), + ProfileConnectionsSearchStatus.complete => context.l10n.formatConnectionsSearched(data.searchedCount), + ProfileConnectionsSearchStatus.error => context.l10n.formatConnectionsSearchStopped(data.searchedCount), ProfileConnectionsSearchStatus.idle => '', }; @@ -492,3 +499,12 @@ String _formatJoinedAgo(DateTime joinedAt) { } return 'now'; } + +String _localizedTabTitle(BuildContext context, ProfileConnectionsTab tab, {bool lowercase = false}) { + final title = switch (tab) { + ProfileConnectionsTab.following => context.l10n.labelFollowing, + ProfileConnectionsTab.followers => context.l10n.labelFollowers, + ProfileConnectionsTab.mutuals => context.l10n.labelMutuals, + }; + return lowercase ? title.toLowerCase() : title; +} diff --git a/lib/features/profile/presentation/profile_context_screen.dart b/lib/features/profile/presentation/profile_context_screen.dart index 91e1006..7cbc7b8 100644 --- a/lib/features/profile/presentation/profile_context_screen.dart +++ b/lib/features/profile/presentation/profile_context_screen.dart @@ -3,11 +3,12 @@ import 'package:bluesky/app_bsky_graph_defs.dart' as bsky_graph; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import 'package:lazurite/core/l10n/l10n.dart'; +import 'package:lazurite/core/theme/theme_extensions.dart'; import 'package:lazurite/features/moderation/presentation/widgets/moderated_avatar.dart'; import 'package:lazurite/features/profile/cubit/profile_context_cubit.dart'; import 'package:lazurite/features/profile/data/profile_context_repository.dart'; import 'package:lazurite/shared/presentation/helpers/navigation_helpers.dart'; -import 'package:lazurite/core/theme/theme_extensions.dart'; class ProfileContextScreen extends StatefulWidget { const ProfileContextScreen({super.key, required this.handle}); @@ -67,7 +68,7 @@ class _ProfileContextScreenState extends State with Single title: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('Profile Context'), + Text(context.l10n.labelProfileContext), Text( '@${widget.handle}', style: context.textTheme.labelSmall?.copyWith(color: context.colorScheme.onSurfaceVariant), @@ -77,9 +78,9 @@ class _ProfileContextScreenState extends State with Single bottom: TabBar( controller: _tabController, tabs: [ - Tab(text: 'Blocked By${state.blockedByCount > 0 ? ' (${state.blockedByCount})' : ''}'), - Tab(text: 'Blocking${state.blockingCount > 0 ? ' (${state.blockingCount})' : ''}'), - Tab(text: 'Lists${state.listsOnCount > 0 ? ' (${state.listsOnCount})' : ''}'), + Tab(text: _tabLabel(context.l10n.labelBlockedBy, state.blockedByCount)), + Tab(text: _tabLabel(context.l10n.labelBlocking, state.blockingCount)), + Tab(text: _tabLabel(context.l10n.labelLists, state.listsOnCount)), ], ), ), @@ -119,14 +120,10 @@ class _BlockedByTab extends StatelessWidget { child: CustomScrollView( physics: const AlwaysScrollableScrollPhysics(), slivers: [ - const SliverToBoxAdapter( + SliverToBoxAdapter( child: Padding( - padding: EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Text( - 'Blocks are a normal part of social media. ' - 'This data is public on the AT Protocol.', - textAlign: TextAlign.center, - ), + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text(context.l10n.messageBlockedByContextNotice, textAlign: TextAlign.center), ), ), SliverToBoxAdapter( @@ -135,7 +132,7 @@ class _BlockedByTab extends StatelessWidget { child: Row( children: [ Text( - '${state.blockedByCount} account${state.blockedByCount == 1 ? '' : 's'}', + context.l10n.formatAccountCount(state.blockedByCount), style: context.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), ), const Spacer(), @@ -144,7 +141,7 @@ class _BlockedByTab extends StatelessWidget { key: const Key('blocked_by_show_accounts'), onPressed: () => cubit.loadBlockedBy(), icon: const Icon(Icons.expand_more), - label: const Text('Show accounts'), + label: Text(context.l10n.buttonShowAccounts), ), ], ), @@ -157,7 +154,7 @@ class _BlockedByTab extends StatelessWidget { hasScrollBody: false, child: Center( child: _ErrorRetry( - message: state.blockedByError ?? 'Failed to load accounts', + message: state.blockedByError ?? context.l10n.errorFailedToLoadAccounts, onRetry: () => cubit.loadBlockedBy(), ), ), @@ -168,8 +165,8 @@ class _BlockedByTab extends StatelessWidget { child: Center( child: Text( state.blockedByCount > 0 - ? 'Found ${state.blockedByCount} blocked-by accounts, but public Bluesky profile details could not be loaded.' - : 'No accounts have blocked this user', + ? context.l10n.formatBlockedByAccountsUnavailable(state.blockedByCount) + : context.l10n.messageNoAccountsBlockedThisUser, textAlign: TextAlign.center, ), ), @@ -191,7 +188,7 @@ class _BlockedByTab extends StatelessWidget { return _UnavailableProfileTile( key: ValueKey('blocked_by_unavailable_${entry.did}'), did: entry.did, - reason: entry.unavailableReason ?? 'Profile unavailable', + reason: entry.unavailableReason ?? context.l10n.messageProfileUnavailable, ); }, ), @@ -207,7 +204,7 @@ class _BlockedByTab extends StatelessWidget { child: Padding( padding: const EdgeInsets.all(16), child: _ErrorRetry( - message: state.blockedByError ?? 'Failed to load more', + message: state.blockedByError ?? context.l10n.errorFailedToLoadMore, onRetry: () => cubit.loadBlockedBy(cursor: state.blockedByCursor), ), ), @@ -230,13 +227,10 @@ class _BlockingTab extends StatelessWidget { final cubit = context.read(); if (!state.isOwnProfile) { - return const Center( + return Center( child: Padding( - padding: EdgeInsets.all(24), - child: Text( - 'Blocking information is only available when viewing your own profile.', - textAlign: TextAlign.center, - ), + padding: const EdgeInsets.all(24), + child: Text(context.l10n.messageBlockingOnlyOwnProfile, textAlign: TextAlign.center), ), ); } @@ -258,7 +252,7 @@ class _BlockingTab extends StatelessWidget { child: Padding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), child: Text( - '${state.blockingCount} account${state.blockingCount == 1 ? '' : 's'}', + context.l10n.formatAccountCount(state.blockingCount), style: context.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), ), ), @@ -278,7 +272,7 @@ class _BlockingTab extends StatelessWidget { hasScrollBody: false, child: Center( child: _ErrorRetry( - message: state.blockingError ?? 'Failed to load accounts', + message: state.blockingError ?? context.l10n.errorFailedToLoadAccounts, onRetry: () => cubit.loadBlocking(), ), ), @@ -289,8 +283,8 @@ class _BlockingTab extends StatelessWidget { child: Center( child: Text( state.blockingUnavailable.isNotEmpty - ? 'Some blocked accounts are suspended or unavailable.' - : 'Not blocking anyone', + ? context.l10n.messageSomeBlockedAccountsUnavailable + : context.l10n.messageNotBlockingAnyone, textAlign: TextAlign.center, ), ), @@ -319,7 +313,7 @@ class _BlockingTab extends StatelessWidget { child: Padding( padding: const EdgeInsets.all(16), child: _ErrorRetry( - message: state.blockingError ?? 'Failed to load more', + message: state.blockingError ?? context.l10n.errorFailedToLoadMore, onRetry: () => cubit.loadBlocking(cursor: state.blockingCursor), ), ), @@ -362,13 +356,13 @@ class _ListsOnTab extends StatelessWidget { hasScrollBody: false, child: Center( child: _ErrorRetry( - message: state.listsOnError ?? 'Failed to load lists', + message: state.listsOnError ?? context.l10n.errorFailedToLoadLists, onRetry: () => cubit.loadListsOn(), ), ), ) else if (state.listsOnStatus == ProfileContextTabStatus.loaded && state.listsOn.isEmpty) - const SliverFillRemaining(hasScrollBody: false, child: Center(child: Text('Not on any lists'))) + SliverFillRemaining(hasScrollBody: false, child: Center(child: Text(context.l10n.messageNotOnAnyLists))) else ...[ ..._buildListSections( context, @@ -387,7 +381,7 @@ class _ListsOnTab extends StatelessWidget { child: Padding( padding: const EdgeInsets.all(16), child: _ErrorRetry( - message: state.listsOnError ?? 'Failed to load more', + message: state.listsOnError ?? context.l10n.errorFailedToLoadMore, onRetry: () => cubit.loadListsOn(cursor: state.listsOnCursor), ), ), @@ -444,8 +438,8 @@ class _UnavailableAccountsCard extends StatelessWidget { children: [ ListTile( leading: const Icon(Icons.warning_amber_rounded), - title: Text('Unavailable accounts (${entries.length})'), - subtitle: const Text('These accounts are suspended or their public profile could not be fetched.'), + title: Text(context.l10n.formatUnavailableAccounts(entries.length)), + subtitle: Text(context.l10n.messageUnavailableAccountsDescription), ), for (final entry in entries) ListTile( @@ -507,7 +501,7 @@ List _buildListSections( List lists, ValueChanged onTap, ) { - final sections = _groupListsByPurpose(lists); + final sections = _groupListsByPurpose(context, lists); return [ for (final section in sections) ...[ SliverToBoxAdapter( @@ -527,7 +521,10 @@ List _buildListSections( ]; } -List<({String title, List lists})> _groupListsByPurpose(List lists) { +List<({String title, List lists})> _groupListsByPurpose( + BuildContext context, + List lists, +) { final buckets = <_ListPurposeGroup, List>{ _ListPurposeGroup.curation: [], _ListPurposeGroup.moderation: [], @@ -540,10 +537,10 @@ List<({String title, List lists})> _groupListsByPurpose(Lis } return [ - (title: 'Curation Lists', lists: buckets[_ListPurposeGroup.curation]!), - (title: 'Moderation Lists', lists: buckets[_ListPurposeGroup.moderation]!), - (title: 'Reference Lists', lists: buckets[_ListPurposeGroup.reference]!), - (title: 'Other Lists', lists: buckets[_ListPurposeGroup.other]!), + (title: context.l10n.labelCurationLists, lists: buckets[_ListPurposeGroup.curation]!), + (title: context.l10n.labelModerationLists, lists: buckets[_ListPurposeGroup.moderation]!), + (title: context.l10n.labelReferenceLists, lists: buckets[_ListPurposeGroup.reference]!), + (title: context.l10n.labelOtherLists, lists: buckets[_ListPurposeGroup.other]!), ].where((section) => section.lists.isNotEmpty).toList(); } @@ -562,6 +559,8 @@ _ListPurposeGroup _purposeGroupFor(bsky_graph.ListView list) { enum _ListPurposeGroup { curation, moderation, reference, other } +String _tabLabel(String label, int count) => count > 0 ? '$label ($count)' : label; + class _ListContextCard extends StatelessWidget { const _ListContextCard({super.key, required this.list, this.onTap}); @@ -616,7 +615,7 @@ class _ListContextCard extends StatelessWidget { const SizedBox(height: 12), _ListMetaChip( icon: Icons.group_outlined, - label: '${list.listItemCount ?? 0} member${(list.listItemCount ?? 0) == 1 ? '' : 's'}', + label: context.l10n.formatMemberCount(list.listItemCount ?? 0), ), if (description != null && description.isNotEmpty) ...[ const SizedBox(height: 12), @@ -640,10 +639,13 @@ class _ListPurposeBadge extends StatelessWidget { Widget build(BuildContext context) { final colorScheme = context.colorScheme; final (label, color) = switch (purpose) { - bsky_graph.KnownListPurpose.appBskyGraphDefsCuratelist => ('CURATE', colorScheme.primary), - bsky_graph.KnownListPurpose.appBskyGraphDefsModlist => ('MOD', colorScheme.error), - bsky_graph.KnownListPurpose.appBskyGraphDefsReferencelist => ('REFERENCE', colorScheme.tertiary), - null => ('LIST', colorScheme.secondary), + bsky_graph.KnownListPurpose.appBskyGraphDefsCuratelist => (context.l10n.labelCurateShort, colorScheme.primary), + bsky_graph.KnownListPurpose.appBskyGraphDefsModlist => (context.l10n.labelModerationShort, colorScheme.error), + bsky_graph.KnownListPurpose.appBskyGraphDefsReferencelist => ( + context.l10n.labelReferenceShort, + colorScheme.tertiary, + ), + null => (context.l10n.labelList.toUpperCase(), colorScheme.secondary), }; return Container( @@ -788,7 +790,7 @@ class _ErrorRetry extends StatelessWidget { children: [ Text(message, textAlign: TextAlign.center), const SizedBox(height: 12), - FilledButton(onPressed: onRetry, child: const Text('Retry')), + FilledButton(onPressed: onRetry, child: Text(context.l10n.buttonRetry)), ], ); } diff --git a/lib/features/profile/presentation/profile_edit_screen.dart b/lib/features/profile/presentation/profile_edit_screen.dart index 97b0dee..d7478f9 100644 --- a/lib/features/profile/presentation/profile_edit_screen.dart +++ b/lib/features/profile/presentation/profile_edit_screen.dart @@ -7,6 +7,7 @@ import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:image_picker/image_picker.dart'; +import 'package:lazurite/core/l10n/l10n.dart'; import 'package:lazurite/core/logging/app_logger.dart'; import 'package:lazurite/core/theme/animation_tokens.dart'; import 'package:lazurite/core/theme/animation_utils.dart'; @@ -91,7 +92,7 @@ class _ProfileEditScreenState extends State { final size = await image.length(); if (size > ProfileImageUpload.maxBytes) { if (mounted) { - showAppSnackBar(context, 'Image must be smaller than 1MB', isError: true); + showAppSnackBar(context, context.l10n.errorImageTooLarge, isError: true); } return; } @@ -99,7 +100,7 @@ class _ProfileEditScreenState extends State { final mimeType = profileImageMimeTypeFor(reportedMimeType: image.mimeType, path: image.path); if (mimeType == null) { if (mounted) { - showAppSnackBar(context, 'Use a JPEG or PNG image', isError: true); + showAppSnackBar(context, context.l10n.errorInvalidProfileImageType, isError: true); } return; } @@ -119,7 +120,7 @@ class _ProfileEditScreenState extends State { } catch (error, stackTrace) { log.w('ProfileEditScreen: failed to pick profile image', error: error, stackTrace: stackTrace); if (mounted) { - showAppSnackBar(context, 'Unable to read selected image', isError: true); + showAppSnackBar(context, context.l10n.errorProfileImageReadFailed, isError: true); } } finally { if (mounted) { @@ -160,12 +161,12 @@ class _ProfileEditScreenState extends State { return; } context.read().add(ProfileLoadRequested(actor: did)); - showAppSnackBar(context, 'Profile updated', behavior: SnackBarBehavior.floating); + showAppSnackBar(context, context.l10n.messageProfileUpdated, behavior: SnackBarBehavior.floating); context.go('/profile/me'); } catch (error, stackTrace) { log.w('ProfileEditScreen: failed to save profile', error: error, stackTrace: stackTrace); if (mounted) { - showAppSnackBar(context, 'Unable to update profile', isError: true); + showAppSnackBar(context, context.l10n.errorUnableToUpdateProfile, isError: true); } } finally { if (mounted) { @@ -197,10 +198,10 @@ class _ProfileEditScreenState extends State { return null; } if (text.characters.length > maxGraphemes) { - return '$label must be $maxGraphemes characters or fewer'; + return context.l10n.formatProfileTextLimit(label, maxGraphemes); } if (utf8.encode(text).length > maxUtf8Bytes) { - return '$label is too long'; + return context.l10n.formatProfileTextTooLong(label); } return null; } @@ -212,7 +213,7 @@ class _ProfileEditScreenState extends State { } final uri = Uri.tryParse(normalized); if (uri == null || !uri.hasScheme || uri.host.isEmpty || (uri.scheme != 'http' && uri.scheme != 'https')) { - return 'Enter a valid website'; + return context.l10n.validationEnterValidWebsite; } return null; } @@ -227,10 +228,10 @@ class _ProfileEditScreenState extends State { return AppScreenEntrance( child: Scaffold( appBar: AppBar( - title: const Text('Edit profile'), + title: Text(context.l10n.labelEditProfile), leading: IconButton( icon: const Icon(Icons.close), - tooltip: 'Cancel', + tooltip: context.l10n.buttonCancel, onPressed: _saving ? null : () => context.go('/profile/me'), ), actions: [ @@ -240,7 +241,7 @@ class _ProfileEditScreenState extends State { icon: _saving ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.check), - label: const Text('Save'), + label: Text(context.l10n.buttonSave), ), ], ), @@ -279,9 +280,9 @@ class _ProfileEditScreenState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Tooltip( - message: 'Change banner image', + message: context.l10n.messageChangeBannerImage, child: Semantics( - label: 'Change banner image', + label: context.l10n.messageChangeBannerImage, button: true, child: AspectRatio( aspectRatio: 3, @@ -302,7 +303,7 @@ class _ProfileEditScreenState extends State { icon: _pickingBanner ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.image_outlined), - label: const Text('Banner'), + label: Text(context.l10n.labelBanner), ), ), ], @@ -317,9 +318,9 @@ class _ProfileEditScreenState extends State { child: Padding( padding: const EdgeInsets.only(left: 16), child: Tooltip( - message: 'Change avatar image', + message: context.l10n.messageChangeAvatarImage, child: Semantics( - label: 'Change avatar image', + label: context.l10n.messageChangeAvatarImage, button: true, child: InkWell( key: const ValueKey('profile_edit_avatar_picker'), @@ -404,8 +405,12 @@ class _ProfileEditScreenState extends State { key: const ValueKey('profile_edit_display_name_field'), controller: _displayNameController, textInputAction: TextInputAction.next, - decoration: const InputDecoration(labelText: 'Display name', prefixIcon: Icon(Icons.badge_outlined)), - validator: (value) => _validateTextLimit(value, 'Display name', maxGraphemes: 64, maxUtf8Bytes: 640), + decoration: InputDecoration( + labelText: context.l10n.labelDisplayName, + prefixIcon: const Icon(Icons.badge_outlined), + ), + validator: (value) => + _validateTextLimit(value, context.l10n.labelDisplayName, maxGraphemes: 64, maxUtf8Bytes: 640), ), const SizedBox(height: 16), TextFormField( @@ -414,16 +419,24 @@ class _ProfileEditScreenState extends State { minLines: 3, maxLines: 6, textInputAction: TextInputAction.newline, - decoration: const InputDecoration(labelText: 'Description', prefixIcon: Icon(Icons.notes_outlined)), - validator: (value) => _validateTextLimit(value, 'Description', maxGraphemes: 256, maxUtf8Bytes: 2560), + decoration: InputDecoration( + labelText: context.l10n.labelDescription, + prefixIcon: const Icon(Icons.notes_outlined), + ), + validator: (value) => + _validateTextLimit(value, context.l10n.labelDescription, maxGraphemes: 256, maxUtf8Bytes: 2560), ), const SizedBox(height: 16), TextFormField( key: const ValueKey('profile_edit_pronouns_field'), controller: _pronounsController, textInputAction: TextInputAction.next, - decoration: const InputDecoration(labelText: 'Pronouns', prefixIcon: Icon(Icons.record_voice_over_outlined)), - validator: (value) => _validateTextLimit(value, 'Pronouns', maxGraphemes: 20, maxUtf8Bytes: 200), + decoration: InputDecoration( + labelText: context.l10n.labelPronouns, + prefixIcon: const Icon(Icons.record_voice_over_outlined), + ), + validator: (value) => + _validateTextLimit(value, context.l10n.labelPronouns, maxGraphemes: 20, maxUtf8Bytes: 200), ), const SizedBox(height: 16), TextFormField( @@ -431,7 +444,7 @@ class _ProfileEditScreenState extends State { controller: _websiteController, keyboardType: TextInputType.url, textInputAction: TextInputAction.done, - decoration: const InputDecoration(labelText: 'Website', prefixIcon: Icon(Icons.link_outlined)), + decoration: InputDecoration(labelText: context.l10n.labelWebsite, prefixIcon: const Icon(Icons.link_outlined)), validator: _validateWebsite, onFieldSubmitted: (_) { final profile = context.read().state.profile; diff --git a/lib/features/profile/presentation/profile_screen.dart b/lib/features/profile/presentation/profile_screen.dart index 77eaab4..1025e06 100644 --- a/lib/features/profile/presentation/profile_screen.dart +++ b/lib/features/profile/presentation/profile_screen.dart @@ -8,6 +8,7 @@ import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; +import 'package:lazurite/core/l10n/l10n.dart'; import 'package:lazurite/core/logging/app_logger.dart'; import 'package:lazurite/core/network/app_view_provider.dart'; import 'package:lazurite/core/network/app_view_web_links.dart'; @@ -21,7 +22,6 @@ import 'package:lazurite/core/theme/theme_extensions.dart'; import 'package:lazurite/core/widgets/sliver_tab_bar_delegate.dart'; import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; import 'package:lazurite/features/compose/presentation/compose_route_args.dart'; -import 'package:lazurite/features/connectivity/connectivity_helpers.dart'; import 'package:lazurite/features/connectivity/cubit/connectivity_cubit.dart'; import 'package:lazurite/features/feed/bloc/feed_bloc.dart'; import 'package:lazurite/features/feed/presentation/widgets/facet_text.dart'; @@ -58,17 +58,10 @@ import 'package:url_launcher/url_launcher.dart'; enum _ProfileFeedSlice { posts, replies, quotes, reposts, media } class _ProfileFeedTabConfig { - const _ProfileFeedTabConfig({ - required this.label, - required this.requestFilter, - required this.slice, - required this.emptyLabel, - }); - - final String label; + const _ProfileFeedTabConfig({required this.requestFilter, required this.slice}); + final FeedFilter requestFilter; final _ProfileFeedSlice slice; - final String emptyLabel; } class ProfileScreen extends StatefulWidget { @@ -83,50 +76,15 @@ class ProfileScreen extends StatefulWidget { class _ProfileScreenState extends State with TickerProviderStateMixin { static const _feedTabs = [ - _ProfileFeedTabConfig( - label: 'Posts', - requestFilter: FeedFilter.postsNoReplies, - slice: _ProfileFeedSlice.posts, - emptyLabel: 'No posts yet', - ), - _ProfileFeedTabConfig( - label: 'Replies', - requestFilter: FeedFilter.postsWithReplies, - slice: _ProfileFeedSlice.replies, - emptyLabel: 'No replies yet', - ), - _ProfileFeedTabConfig( - label: 'Quotes', - requestFilter: FeedFilter.postsWithReplies, - slice: _ProfileFeedSlice.quotes, - emptyLabel: 'No quotes yet', - ), - _ProfileFeedTabConfig( - label: 'Reposts', - requestFilter: FeedFilter.postsWithReplies, - slice: _ProfileFeedSlice.reposts, - emptyLabel: 'No reposts yet', - ), - _ProfileFeedTabConfig( - label: 'Media', - requestFilter: FeedFilter.postsWithMedia, - slice: _ProfileFeedSlice.media, - emptyLabel: 'No media posts yet', - ), + _ProfileFeedTabConfig(requestFilter: FeedFilter.postsNoReplies, slice: _ProfileFeedSlice.posts), + _ProfileFeedTabConfig(requestFilter: FeedFilter.postsWithReplies, slice: _ProfileFeedSlice.replies), + _ProfileFeedTabConfig(requestFilter: FeedFilter.postsWithReplies, slice: _ProfileFeedSlice.quotes), + _ProfileFeedTabConfig(requestFilter: FeedFilter.postsWithReplies, slice: _ProfileFeedSlice.reposts), + _ProfileFeedTabConfig(requestFilter: FeedFilter.postsWithMedia, slice: _ProfileFeedSlice.media), ]; - static const _baseTabLabelsOwn = ['POSTS', 'REPLIES', 'QUOTES', 'REPOSTS', 'MEDIA', 'LISTS', 'STARTER PACKS']; - static const _baseTabLabelsOther = [ - 'POSTS', - 'REPLIES', - 'QUOTES', - 'REPOSTS', - 'MEDIA', - 'LIKED', - 'LISTS', - 'STARTER PACKS', - ]; - static const _suggestedTabLabel = 'SUGGESTED'; + static const _baseTabCountOwn = 7; + static const _baseTabCountOther = 8; static const _coverRefreshTriggerDistance = 72.0; late TabController _tabController; @@ -151,7 +109,7 @@ class _ProfileScreenState extends State with TickerProviderStateM void initState() { super.initState(); _showSuggestedTab = _shouldShowSuggestedTab(context.read().state.profile); - _tabController = TabController(length: _tabLabels.length, vsync: this); + _tabController = TabController(length: _tabCount, vsync: this); _loadProfileAndFeed(); } @@ -312,10 +270,36 @@ class _ProfileScreenState extends State with TickerProviderStateM }); } - List get _baseTabLabels => _showSuggestedTab ? _baseTabLabelsOther : _baseTabLabelsOwn; + int get _baseTabCount => _showSuggestedTab ? _baseTabCountOther : _baseTabCountOwn; + + int get _tabCount => _baseTabCount + (_showSuggestedTab ? 1 : 0); + + List _localizedTabLabels(BuildContext context) { + final l10n = context.l10n; + final labels = [ + l10n.labelPosts.toUpperCase(), + l10n.labelReplies.toUpperCase(), + l10n.labelQuotes.toUpperCase(), + l10n.labelReposts.toUpperCase(), + l10n.labelMedia.toUpperCase(), + if (_showSuggestedTab) l10n.labelLiked.toUpperCase(), + l10n.labelLists.toUpperCase(), + l10n.labelStarterPacks.toUpperCase(), + if (_showSuggestedTab) l10n.labelSuggested.toUpperCase(), + ]; + return labels; + } - List get _tabLabels => - _showSuggestedTab ? [..._baseTabLabels, _suggestedTabLabel] : List.of(_baseTabLabels); + String _emptyLabelForSlice(BuildContext context, _ProfileFeedSlice slice) { + final l10n = context.l10n; + return switch (slice) { + _ProfileFeedSlice.posts => l10n.messageNoPostsYet, + _ProfileFeedSlice.replies => l10n.messageNoRepliesYet, + _ProfileFeedSlice.quotes => l10n.messageNoQuotesYet, + _ProfileFeedSlice.reposts => l10n.messageNoRepostsYet, + _ProfileFeedSlice.media => l10n.messageNoMediaPostsYet, + }; + } _ProfileFeedTabConfig get _currentFeedTab => _feedTabs[_tabController.index < _feedTabs.length ? _tabController.index : 0]; @@ -362,11 +346,11 @@ class _ProfileScreenState extends State with TickerProviderStateM return; } - final maxIndex = show ? _baseTabLabelsOther.length : _baseTabLabelsOwn.length - 1; + final maxIndex = show ? _baseTabCountOther : _baseTabCountOwn - 1; final nextIndex = _tabController.index.clamp(0, maxIndex); final previousController = _tabController; _showSuggestedTab = show; - _tabController = TabController(length: _tabLabels.length, vsync: this, initialIndex: nextIndex); + _tabController = TabController(length: _tabCount, vsync: this, initialIndex: nextIndex); setState(() {}); WidgetsBinding.instance.addPostFrameCallback((_) { @@ -376,7 +360,11 @@ class _ProfileScreenState extends State with TickerProviderStateM String _appBarTitle(ProfileViewDetailed? profile) { final authState = context.read().state; - return profile?.displayName ?? profile?.handle ?? widget.actor ?? authState.tokens?.handle ?? 'Profile'; + return profile?.displayName ?? + profile?.handle ?? + widget.actor ?? + authState.tokens?.handle ?? + context.l10n.labelProfileTitle; } void _openProfilePostSearch(BuildContext context, ProfileViewDetailed profile) { @@ -522,7 +510,7 @@ class _ProfileScreenState extends State with TickerProviderStateM sourceState: stateForTab, requestFilter: tab.requestFilter, slice: tab.slice, - emptyLabel: tab.emptyLabel, + emptyLabel: _emptyLabelForSlice(context, tab.slice), profile: actorScopedProfile, expectedActor: expectedActor, ), @@ -587,7 +575,7 @@ class _ProfileScreenState extends State with TickerProviderStateM IconButton( key: const Key('profile_edit_header_button'), icon: const Icon(Icons.edit_outlined), - tooltip: 'Edit profile', + tooltip: context.l10n.labelEditProfile, onPressed: () => context.push('/profile/me/edit'), ), if (actorScopedProfile != null && isOwnProfile) @@ -600,7 +588,7 @@ class _ProfileScreenState extends State with TickerProviderStateM IconButton( key: const Key('profile_search_posts_button'), icon: const Icon(Icons.search), - tooltip: 'Search this profile\'s posts', + tooltip: context.l10n.messageSearchThisProfilesPostsPlaceholder, onPressed: () => _openProfilePostSearch(context, actorScopedProfile), ), IconButton( @@ -632,7 +620,7 @@ class _ProfileScreenState extends State with TickerProviderStateM delegate: SliverTabBarDelegate( TabBar( controller: _tabController, - tabs: [for (final label in _tabLabels) Tab(text: label)], + tabs: [for (final label in _localizedTabLabels(context)) Tab(text: label)], onTap: (index) { if (index < _feedTabs.length) { _loadFeedOnly(filter: _feedTabs[index].requestFilter); @@ -675,7 +663,7 @@ class _ProfileScreenState extends State with TickerProviderStateM final jumpToTopButton = FloatingActionButton.small( key: const ValueKey('profile-jump-top-fab'), heroTag: 'profile-jump-top-fab', - tooltip: 'Jump to top', + tooltip: context.l10n.tooltipJumpToTop, onPressed: _jumpToTop, backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest.withValues(alpha: 0.9), foregroundColor: Theme.of(context).colorScheme.onSurfaceVariant, @@ -775,11 +763,11 @@ class _ProfileScreenState extends State with TickerProviderStateM child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Unable to load profile', style: context.textTheme.titleLarge), + Text(context.l10n.errorFailedToLoadProfile, style: context.textTheme.titleLarge), const SizedBox(height: 8), - Text(errorMessage ?? 'Unknown error', style: context.textTheme.bodyMedium), + Text(errorMessage ?? context.l10n.errorUnknown, style: context.textTheme.bodyMedium), const SizedBox(height: 12), - FilledButton(onPressed: _loadProfileAndFeed, child: const Text('Try again')), + FilledButton(onPressed: _loadProfileAndFeed, child: Text(context.l10n.buttonTryAgain)), ], ), ); @@ -809,7 +797,7 @@ class _ProfileScreenState extends State with TickerProviderStateM _buildMetaChip( context, Icons.calendar_today_outlined, - 'Joined ${DateFormat.yMMMM().format(profile.createdAt!)}', + context.l10n.formatJoinedDate(DateFormat.yMMMM().format(profile.createdAt!)), ), ]; @@ -866,7 +854,7 @@ class _ProfileScreenState extends State with TickerProviderStateM _buildStat( context, profile.followsCount ?? 0, - 'Following', + context.l10n.labelFollowing, key: const ValueKey('profile_following_stat'), onTap: () => _openConnections(context, profile, ProfileConnectionsTab.following), ), @@ -874,12 +862,12 @@ class _ProfileScreenState extends State with TickerProviderStateM _buildStat( context, profile.followersCount ?? 0, - 'Followers', + context.l10n.labelFollowers, key: const ValueKey('profile_followers_stat'), onTap: () => _openConnections(context, profile, ProfileConnectionsTab.followers), ), const SizedBox(width: 24), - _buildStat(context, profile.postsCount ?? 0, 'Posts'), + _buildStat(context, profile.postsCount ?? 0, context.l10n.labelPosts), ], ), ), @@ -892,12 +880,12 @@ class _ProfileScreenState extends State with TickerProviderStateM OutlinedButton.icon( onPressed: () => context.push('/bookmarks'), icon: const Icon(Icons.bookmark_outline), - label: const Text('Bookmarks'), + label: Text(context.l10n.labelBookmarks), ), OutlinedButton.icon( onPressed: () => context.push('/liked'), icon: const Icon(Icons.favorite_outline), - label: const Text('Liked'), + label: Text(context.l10n.labelLiked), ), ], ), @@ -1024,14 +1012,14 @@ class _ProfileScreenState extends State with TickerProviderStateM items: [ OptionsSheetItem( leading: const Icon(Icons.hub_outlined), - title: 'Profile Context', + title: context.l10n.labelProfileContext, onTap: () => context.push( '/profile-context?did=${Uri.encodeComponent(profile.did)}&handle=${Uri.encodeComponent(profile.handle)}', ), ), OptionsSheetItem( leading: const Icon(Icons.cleaning_services_outlined), - title: 'Clean Follows', + title: context.l10n.labelCleanFollows, onTap: () => context.push('/settings/clean-follows'), ), ], @@ -1044,15 +1032,15 @@ class _ProfileScreenState extends State with TickerProviderStateM items: [ OptionsSheetItem( leading: const Icon(Icons.copy), - title: 'Copy DID', + title: context.l10n.labelCopyDid, onTap: () { Clipboard.setData(ClipboardData(text: profile.did)); - showAppSnackBar(context, 'DID copied to clipboard', behavior: SnackBarBehavior.floating); + showAppSnackBar(context, context.l10n.formatDidCopied, behavior: SnackBarBehavior.floating); }, ), OptionsSheetItem( leading: const Icon(Icons.share_outlined), - title: 'Share Profile', + title: context.l10n.labelShareProfile, onTap: () => ShareHelper.shareText( context, AppViewWebLinks.profile(profile.handle, appViewProvider: _resolveAppViewProvider(context)), @@ -1060,17 +1048,17 @@ class _ProfileScreenState extends State with TickerProviderStateM ), OptionsSheetItem( leading: const Icon(Icons.playlist_add_outlined), - title: 'Add to list', + title: context.l10n.labelAddToList, onTap: () => _showAddToList(context, profile), ), OptionsSheetItem( leading: const Icon(Icons.people_outline), - title: 'Suggested Follows', + title: context.l10n.labelSuggestedFollows, onTap: () => _showSuggestedFollows(context, profile), ), OptionsSheetItem( leading: const Icon(Icons.hub_outlined), - title: 'Profile Context', + title: context.l10n.labelProfileContext, onTap: () => context.push( '/profile-context?did=${Uri.encodeComponent(profile.did)}&handle=${Uri.encodeComponent(profile.handle)}', ), @@ -1106,7 +1094,10 @@ class _ProfileScreenState extends State with TickerProviderStateM children: [ Padding( padding: const EdgeInsets.symmetric(vertical: 12), - child: Text('Add to list', style: context.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)), + child: Text( + context.l10n.labelAddToList, + style: context.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), + ), ), const Divider(height: 1), Expanded( @@ -1117,11 +1108,11 @@ class _ProfileScreenState extends State with TickerProviderStateM } if (state.status == AddToListStatus.error) { - return Center(child: Text(state.errorMessage ?? 'Failed to load lists')); + return Center(child: Text(state.errorMessage ?? context.l10n.errorFailedToLoadLists)); } if (state.lists.isEmpty) { - return const Center(child: Text('No lists yet')); + return Center(child: Text(context.l10n.messageNoListsYet)); } return ListView.builder( @@ -1215,7 +1206,7 @@ class _ProfileScreenState extends State with TickerProviderStateM return FloatingActionButton( key: const ValueKey('profile-compose-fab'), heroTag: 'profile-compose-fab', - tooltip: isOffline ? offlineActionMessage('compose a post') : 'Compose', + tooltip: isOffline ? context.l10n.formatOfflineReconnectAction('compose a post') : context.l10n.buttonCompose, onPressed: isOffline ? null : () => context.push('/compose', extra: ComposeRouteArgs(initialText: initialText)), @@ -1263,11 +1254,11 @@ class _ProfileScreenState extends State with TickerProviderStateM child: Column( mainAxisSize: MainAxisSize.min, children: [ - Text(sourceState.errorMessage ?? 'Failed to load posts'), + Text(sourceState.errorMessage ?? context.l10n.errorFailedToLoadPosts), const SizedBox(height: 12), FilledButton( onPressed: () => _loadFeedOnly(filter: requestFilter), - child: const Text('Retry'), + child: Text(context.l10n.buttonRetry), ), ], ), @@ -1605,7 +1596,7 @@ class _SuggestedFollowsTabState extends State<_SuggestedFollowsTab> { Widget build(BuildContext context) { final cubit = _cubit; if (cubit == null) { - return const Center(child: Text('Suggested follows are unavailable right now.')); + return Center(child: Text(context.l10n.messageSuggestedFollowsUnavailable)); } return BlocProvider.value( @@ -1666,9 +1657,9 @@ class _ProfileListsPaneState extends State<_ProfileListsPane> { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Text(state.errorMessage ?? 'Failed to load lists'), + Text(state.errorMessage ?? context.l10n.errorFailedToLoadLists), const SizedBox(height: 12), - FilledButton(onPressed: () => _cubit.refresh(), child: const Text('Retry')), + FilledButton(onPressed: () => _cubit.refresh(), child: Text(context.l10n.buttonRetry)), ], ), ); @@ -1682,7 +1673,7 @@ class _ProfileListsPaneState extends State<_ProfileListsPane> { .toList(growable: false); if (lists.isEmpty) { - return const Center(child: Text('No lists yet')); + return Center(child: Text(context.l10n.messageNoListsYet)); } return RefreshIndicator( diff --git a/lib/features/profile/presentation/widgets/profile_action_buttons.dart b/lib/features/profile/presentation/widgets/profile_action_buttons.dart index 4cf86b1..185889b 100644 --- a/lib/features/profile/presentation/widgets/profile_action_buttons.dart +++ b/lib/features/profile/presentation/widgets/profile_action_buttons.dart @@ -1,7 +1,7 @@ import 'package:lazurite/core/theme/theme_extensions.dart'; import 'package:flutter/material.dart'; -import 'package:lazurite/features/connectivity/connectivity_helpers.dart'; +import 'package:lazurite/core/l10n/l10n.dart'; import 'package:lazurite/shared/presentation/helpers/haptic_helper.dart'; import 'package:lazurite/shared/presentation/widgets/confirmation_dialog.dart'; @@ -56,50 +56,56 @@ class ProfileActionButtons extends StatelessWidget { } Widget _buildFollowButton(BuildContext context) { + final l10n = context.l10n; if (isBlocked) { return _ActionButton( - label: 'Unblock', + label: l10n.buttonUnblock, onPressed: isOffline || onUnblock == null ? null : () => _confirmUnblock(context), isLoading: isLoadingBlock, foregroundColor: context.colorScheme.onError, backgroundColor: context.colorScheme.error, - tooltip: isOffline ? offlineActionMessage('unblock this account') : null, + tooltip: isOffline ? l10n.formatOfflineReconnectAction(l10n.buttonUnblock.toLowerCase()) : null, ); } if (isFollowing) { return _ActionButton( - label: 'Following', + label: l10n.buttonFollowing, onPressed: isOffline || onUnfollow == null ? null : () => _confirmUnfollow(context), isLoading: isLoadingFollow, isSecondary: true, - tooltip: isOffline ? offlineActionMessage('change your follow state') : null, + tooltip: isOffline ? l10n.formatOfflineReconnectAction('change your follow state') : null, ); } return _ActionButton( - label: 'Follow', + label: l10n.buttonFollow, onPressed: isOffline ? null : onFollow, isLoading: isLoadingFollow, - tooltip: isOffline ? offlineActionMessage('follow this account') : null, + tooltip: isOffline ? l10n.formatOfflineReconnectAction('follow this account') : null, ); } Widget _buildMoreButton(BuildContext context) { + final l10n = context.l10n; final List> menuItems = []; if (!isBlocked) { if (isMuted) { menuItems.add( PopupMenuItem( - child: const Row(children: [Icon(Icons.volume_up_outlined), SizedBox(width: 8), Text('Unmute')]), + child: Row( + children: [const Icon(Icons.volume_up_outlined), const SizedBox(width: 8), Text(l10n.buttonUnmute)], + ), onTap: () => _confirmUnmute(context), ), ); } else { menuItems.add( PopupMenuItem( - child: const Row(children: [Icon(Icons.volume_off_outlined), SizedBox(width: 8), Text('Mute')]), + child: Row( + children: [const Icon(Icons.volume_off_outlined), const SizedBox(width: 8), Text(l10n.buttonMute)], + ), onTap: () => _confirmMute(context), ), ); @@ -107,11 +113,11 @@ class ProfileActionButtons extends StatelessWidget { menuItems.add( PopupMenuItem( - child: const Row( + child: Row( children: [ - Icon(Icons.block_outlined, color: Colors.red), - SizedBox(width: 8), - Text('Block', style: TextStyle(color: Colors.red)), + const Icon(Icons.block_outlined, color: Colors.red), + const SizedBox(width: 8), + Text(l10n.buttonBlock, style: const TextStyle(color: Colors.red)), ], ), onTap: () => _confirmBlock(context), @@ -123,7 +129,9 @@ class ProfileActionButtons extends StatelessWidget { menuItems.add( PopupMenuItem( onTap: onAddToList, - child: const Row(children: [Icon(Icons.playlist_add_outlined), SizedBox(width: 8), Text('Add to list')]), + child: Row( + children: [const Icon(Icons.playlist_add_outlined), const SizedBox(width: 8), Text(l10n.labelAddToList)], + ), ), ); } @@ -132,11 +140,11 @@ class ProfileActionButtons extends StatelessWidget { const PopupMenuDivider(), PopupMenuItem( onTap: onMore, - child: const Row( + child: Row( children: [ - Icon(Icons.report_outlined, color: Colors.orange), - SizedBox(width: 8), - Text('Report', style: TextStyle(color: Colors.orange)), + const Icon(Icons.report_outlined, color: Colors.orange), + const SizedBox(width: 8), + Text(l10n.labelReport, style: const TextStyle(color: Colors.orange)), ], ), ), @@ -148,7 +156,7 @@ class ProfileActionButtons extends StatelessWidget { itemBuilder: (_) => menuItems, ); if (isOffline) { - button = Tooltip(message: offlineActionMessage('manage this profile'), child: button); + button = Tooltip(message: l10n.formatOfflineReconnectAction('manage this profile'), child: button); } return button; } @@ -157,9 +165,9 @@ class ProfileActionButtons extends StatelessWidget { HapticHelper.mediumImpact(); await showConfirmationDialog( context: context, - title: const Text('Unfollow?'), - content: const Text('You will no longer see their posts in your feed.'), - confirmLabel: 'Unfollow', + title: Text(context.l10n.dialogUnfollowAccountTitle), + content: Text(context.l10n.dialogUnfollowAccountContent), + confirmLabel: context.l10n.buttonUnfollow, onConfirmed: onUnfollow, ); } @@ -168,9 +176,9 @@ class ProfileActionButtons extends StatelessWidget { HapticHelper.mediumImpact(); await showConfirmationDialog( context: context, - title: const Text('Mute Account?'), - content: const Text('You will no longer see their posts or receive notifications from them.'), - confirmLabel: 'Mute', + title: Text(context.l10n.dialogMuteAccountTitle), + content: Text(context.l10n.dialogMuteAccountContent), + confirmLabel: context.l10n.buttonMute, onConfirmed: onMute, ); } @@ -179,9 +187,9 @@ class ProfileActionButtons extends StatelessWidget { HapticHelper.mediumImpact(); await showConfirmationDialog( context: context, - title: const Text('Unmute Account?'), - content: const Text('You will see their posts and receive notifications again.'), - confirmLabel: 'Unmute', + title: Text(context.l10n.dialogUnmuteAccountTitle), + content: Text(context.l10n.dialogUnmuteAccountContent), + confirmLabel: context.l10n.buttonUnmute, onConfirmed: onUnmute, ); } @@ -194,13 +202,11 @@ class ProfileActionButtons extends StatelessWidget { children: [ Icon(Icons.block, color: context.colorScheme.error), const SizedBox(width: 8), - const Text('Block Account?'), + Text(context.l10n.dialogBlockAccountTitle), ], ), - content: const Text( - 'They will not be able to see your posts or interact with you. They will not be notified that you blocked them.', - ), - confirmLabel: 'Block', + content: Text(context.l10n.dialogBlockAccountContent), + confirmLabel: context.l10n.buttonBlock, confirmDestructive: true, onConfirmed: onBlock, ); @@ -210,9 +216,9 @@ class ProfileActionButtons extends StatelessWidget { HapticHelper.mediumImpact(); await showConfirmationDialog( context: context, - title: const Text('Unblock Account?'), - content: const Text('They will be able to see your posts and interact with you again.'), - confirmLabel: 'Unblock', + title: Text(context.l10n.dialogUnblockAccountTitle), + content: Text(context.l10n.dialogUnblockAccountContent), + confirmLabel: context.l10n.buttonUnblock, onConfirmed: onUnblock, ); } diff --git a/lib/features/profile/presentation/widgets/profile_liked_posts_pane.dart b/lib/features/profile/presentation/widgets/profile_liked_posts_pane.dart index 6f62beb..b7ab53c 100644 --- a/lib/features/profile/presentation/widgets/profile_liked_posts_pane.dart +++ b/lib/features/profile/presentation/widgets/profile_liked_posts_pane.dart @@ -2,6 +2,7 @@ import 'package:bluesky/moderation.dart' as bsky_moderation; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import 'package:lazurite/core/l10n/l10n.dart'; import 'package:lazurite/features/auth/bloc/auth_bloc.dart'; import 'package:lazurite/features/feed/presentation/widgets/post_card_with_actions.dart'; import 'package:lazurite/features/profile/data/profile_repository.dart'; @@ -103,14 +104,14 @@ class _ProfileLikedPostsPaneState extends State { children: [ Text(_error!), const SizedBox(height: 12), - FilledButton(onPressed: _loadInitial, child: const Text('Retry')), + FilledButton(onPressed: _loadInitial, child: Text(context.l10n.buttonRetry)), ], ), ); } if (_entries.isEmpty) { - return const Center(child: Text('No liked posts yet')); + return Center(child: Text(context.l10n.messageNoLikedPostsYet)); } final accountDid = context.read().state.tokens?.did ?? ''; @@ -165,12 +166,12 @@ class _UnavailableLikedPostCard extends StatelessWidget { margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), child: ListTile( leading: const Icon(Icons.hide_source_outlined), - title: const Text('Unavailable liked post'), + title: Text(context.l10n.labelUnavailableLikedPost), subtitle: Text(reason), trailing: IconButton( icon: const Icon(Icons.open_in_new), onPressed: () => context.push('/post?uri=${Uri.encodeQueryComponent(subjectUri)}'), - tooltip: 'Open', + tooltip: context.l10n.buttonOpen, ), ), ); diff --git a/lib/features/profile/presentation/widgets/profile_starter_packs_pane.dart b/lib/features/profile/presentation/widgets/profile_starter_packs_pane.dart index 9655877..1cc04f9 100644 --- a/lib/features/profile/presentation/widgets/profile_starter_packs_pane.dart +++ b/lib/features/profile/presentation/widgets/profile_starter_packs_pane.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import 'package:lazurite/core/l10n/l10n.dart'; import 'package:lazurite/features/starter_packs/cubit/actor_starter_packs_cubit.dart'; import 'package:lazurite/features/starter_packs/data/starter_pack_repository.dart'; import 'package:lazurite/features/starter_packs/presentation/widgets/starter_pack_card.dart'; @@ -53,11 +54,11 @@ class _ProfileStarterPacksPaneState extends State { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Text(state.errorMessage ?? 'Failed to load starter packs'), + Text(state.errorMessage ?? context.l10n.errorFailedToLoadStarterPacks), const SizedBox(height: 12), FilledButton( onPressed: () => _cubit.load(actor: widget.actor), - child: const Text('Retry'), + child: Text(context.l10n.buttonRetry), ), ], ), @@ -65,7 +66,7 @@ class _ProfileStarterPacksPaneState extends State { } if (state.starterPacks.isEmpty) { - return const Center(child: Text('No starter packs yet')); + return Center(child: Text(context.l10n.messageNoStarterPacksYet)); } return RefreshIndicator( diff --git a/lib/features/profile/presentation/widgets/report_dialog.dart b/lib/features/profile/presentation/widgets/report_dialog.dart index e6e1f7d..2ea4684 100644 --- a/lib/features/profile/presentation/widgets/report_dialog.dart +++ b/lib/features/profile/presentation/widgets/report_dialog.dart @@ -2,9 +2,10 @@ import 'package:atproto/com_atproto_moderation_defs.dart'; import 'package:atproto_core/atproto_core.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lazurite/core/l10n/l10n.dart'; +import 'package:lazurite/core/theme/theme_extensions.dart'; import 'package:lazurite/features/profile/cubit/profile_action_cubit.dart'; import 'package:lazurite/shared/presentation/helpers/haptic_helper.dart'; -import 'package:lazurite/core/theme/theme_extensions.dart'; enum _ReportType { post, actor } @@ -34,39 +35,6 @@ class _ReportDialogState extends State { ReasonType? _selectedReason; bool _isSubmitting = false; - static const _reasonOptions = [ - ( - type: ReasonType.knownValue(data: KnownReasonType.comAtprotoModerationDefsReasonSpam), - label: 'Spam', - description: 'Spam or unsolicited content', - ), - ( - type: ReasonType.knownValue(data: KnownReasonType.comAtprotoModerationDefsReasonViolation), - label: 'Violation', - description: 'Violates community guidelines', - ), - ( - type: ReasonType.knownValue(data: KnownReasonType.comAtprotoModerationDefsReasonMisleading), - label: 'Misleading', - description: 'Misleading or deceptive content', - ), - ( - type: ReasonType.knownValue(data: KnownReasonType.comAtprotoModerationDefsReasonSexual), - label: 'Sexual Content', - description: 'Unwanted sexual content', - ), - ( - type: ReasonType.knownValue(data: KnownReasonType.comAtprotoModerationDefsReasonRude), - label: 'Harassment', - description: 'Harassment or rude behaviour', - ), - ( - type: ReasonType.knownValue(data: KnownReasonType.comAtprotoModerationDefsReasonOther), - label: 'Other', - description: 'Other reason (requires explanation)', - ), - ]; - @override void dispose() { _reasonController.dispose(); @@ -75,24 +43,25 @@ class _ReportDialogState extends State { @override Widget build(BuildContext context) { - final title = widget._type == _ReportType.post ? 'Report Post' : 'Report Account'; + final l10n = context.l10n; + final title = widget._type == _ReportType.post ? l10n.labelReportPost : l10n.labelReportAccount; final target = widget.authorHandle; return AlertDialog( - title: Text('$title by @$target'), + title: Text(l10n.formatProfileReportTitle(title, target)), content: SizedBox( width: double.maxFinite, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Reason', style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)), + Text(l10n.labelReportReason, style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)), const SizedBox(height: 8), - ..._reasonOptions.map((option) => _buildReasonOption(option)), + ..._reasonOptions(context).map((option) => _buildReasonOption(option)), if (_requiresExplanation) ...[ const SizedBox(height: 16), Text( - 'Explanation (required)', + l10n.labelReportReasonExplanationRequired, style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600), ), const SizedBox(height: 8), @@ -100,9 +69,9 @@ class _ReportDialogState extends State { controller: _reasonController, maxLines: 3, maxLength: 2000, - decoration: const InputDecoration( - hintText: 'Please explain why you are reporting this...', - border: OutlineInputBorder(), + decoration: InputDecoration( + hintText: l10n.messageReportExplanationHint, + border: const OutlineInputBorder(), ), ), ], @@ -110,17 +79,53 @@ class _ReportDialogState extends State { ), ), actions: [ - TextButton(onPressed: _isSubmitting ? null : () => Navigator.pop(context), child: const Text('Cancel')), + TextButton(onPressed: _isSubmitting ? null : () => Navigator.pop(context), child: Text(l10n.buttonCancel)), FilledButton( onPressed: _canSubmit ? _submit : null, child: _isSubmitting ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) - : const Text('Submit Report'), + : Text(l10n.buttonSubmitReport), ), ], ); } + List<({ReasonType type, String label, String description})> _reasonOptions(BuildContext context) { + final l10n = context.l10n; + return [ + ( + type: const ReasonType.knownValue(data: KnownReasonType.comAtprotoModerationDefsReasonSpam), + label: l10n.labelReportReasonSpam, + description: l10n.messageReportReasonSpamDescription, + ), + ( + type: const ReasonType.knownValue(data: KnownReasonType.comAtprotoModerationDefsReasonViolation), + label: l10n.labelReportReasonViolation, + description: l10n.messageReportReasonViolationDescription, + ), + ( + type: const ReasonType.knownValue(data: KnownReasonType.comAtprotoModerationDefsReasonMisleading), + label: l10n.labelReportReasonMisleading, + description: l10n.messageReportReasonMisleadingDescription, + ), + ( + type: const ReasonType.knownValue(data: KnownReasonType.comAtprotoModerationDefsReasonSexual), + label: l10n.labelReportReasonSexualContent, + description: l10n.messageReportReasonSexualContentDescription, + ), + ( + type: const ReasonType.knownValue(data: KnownReasonType.comAtprotoModerationDefsReasonRude), + label: l10n.labelReportReasonHarassment, + description: l10n.messageReportReasonHarassmentDescription, + ), + ( + type: const ReasonType.knownValue(data: KnownReasonType.comAtprotoModerationDefsReasonOther), + label: l10n.labelReportReasonOther, + description: l10n.messageReportReasonOtherDescription, + ), + ]; + } + Widget _buildReasonOption(({ReasonType type, String label, String description}) option) { final isSelected = _selectedReason == option.type; final theme = Theme.of(context); @@ -225,15 +230,15 @@ class _ReportDialogState extends State { showDialog( context: context, builder: (context) => AlertDialog( - title: const Row( + title: Row( children: [ - Icon(Icons.check_circle, color: Colors.green), - SizedBox(width: 8), - Text('Report Submitted'), + const Icon(Icons.check_circle, color: Colors.green), + const SizedBox(width: 8), + Text(context.l10n.labelReportSubmitted), ], ), - content: Text('Thank you. Your report (ID: $reportId) has been submitted.'), - actions: [FilledButton(onPressed: () => Navigator.pop(context), child: const Text('OK'))], + content: Text(context.l10n.formatReportSubmitted(reportId)), + actions: [FilledButton(onPressed: () => Navigator.pop(context), child: Text(context.l10n.buttonOk))], ), ); } @@ -242,15 +247,15 @@ class _ReportDialogState extends State { showDialog( context: context, builder: (context) => AlertDialog( - title: const Row( + title: Row( children: [ - Icon(Icons.error_outline, color: Colors.red), - SizedBox(width: 8), - Text('Report Failed'), + const Icon(Icons.error_outline, color: Colors.red), + const SizedBox(width: 8), + Text(context.l10n.errorReportFailedTitle), ], ), - content: const Text('Unable to submit your report. Please try again later.'), - actions: [FilledButton(onPressed: () => Navigator.pop(context), child: const Text('OK'))], + content: Text(context.l10n.errorReportFailed), + actions: [FilledButton(onPressed: () => Navigator.pop(context), child: Text(context.l10n.buttonOk))], ), ); } diff --git a/lib/features/profile/presentation/widgets/suggested_follows_list.dart b/lib/features/profile/presentation/widgets/suggested_follows_list.dart index de59a60..56408b9 100644 --- a/lib/features/profile/presentation/widgets/suggested_follows_list.dart +++ b/lib/features/profile/presentation/widgets/suggested_follows_list.dart @@ -1,6 +1,7 @@ import 'package:bluesky/app_bsky_actor_defs.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:lazurite/core/l10n/l10n.dart'; import 'package:lazurite/features/profile/cubit/profile_action_cubit.dart'; import 'package:lazurite/features/profile/cubit/suggested_follows_cubit.dart'; import 'package:lazurite/features/profile/data/profile_action_repository.dart'; @@ -15,14 +16,14 @@ class SuggestedFollowsList extends StatelessWidget { required this.actor, this.scrollController, this.onProfileTap, - this.emptyMessage = 'No suggestions found', + this.emptyMessage, this.padding = EdgeInsets.zero, }); final String actor; final ScrollController? scrollController; final ValueChanged? onProfileTap; - final String emptyMessage; + final String? emptyMessage; final EdgeInsetsGeometry padding; @override @@ -35,14 +36,17 @@ class SuggestedFollowsList extends StatelessWidget { if (state.hasError) { return ErrorState( - title: 'Failed to load suggestions', - message: state.errorMessage ?? 'Unknown error', + title: context.l10n.errorFailedToLoadSuggestions, + message: state.errorMessage ?? context.l10n.errorUnknown, onRetry: () => context.read().load(actor), ); } if (state.isEmpty) { - return EmptyState(message: emptyMessage, icon: Icons.person_search_outlined); + return EmptyState( + message: emptyMessage ?? context.l10n.messageNoSuggestionsFound, + icon: Icons.person_search_outlined, + ); } return ListView.builder( @@ -162,9 +166,9 @@ class _FollowButton extends StatelessWidget { } if (isFollowing) { - return OutlinedButton(onPressed: onPressed, child: const Text('Following')); + return OutlinedButton(onPressed: onPressed, child: Text(context.l10n.buttonFollowing)); } - return FilledButton(onPressed: onPressed, child: const Text('Follow')); + return FilledButton(onPressed: onPressed, child: Text(context.l10n.buttonFollow)); } } diff --git a/lib/features/profile/presentation/widgets/suggested_follows_sheet.dart b/lib/features/profile/presentation/widgets/suggested_follows_sheet.dart index c8163e8..1ac4d27 100644 --- a/lib/features/profile/presentation/widgets/suggested_follows_sheet.dart +++ b/lib/features/profile/presentation/widgets/suggested_follows_sheet.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:lazurite/core/l10n/l10n.dart'; import 'package:lazurite/features/profile/presentation/widgets/suggested_follows_list.dart'; import 'package:lazurite/shared/presentation/helpers/navigation_helpers.dart'; import 'package:lazurite/core/theme/theme_extensions.dart'; @@ -20,7 +21,7 @@ class SuggestedFollowsSheet extends StatelessWidget { Padding( padding: const EdgeInsets.symmetric(vertical: 12), child: Text( - 'Suggested Follows', + context.l10n.labelSuggestedFollows, style: context.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), ), ), diff --git a/lib/features/starter_packs/presentation/actor_starter_packs_screen.dart b/lib/features/starter_packs/presentation/actor_starter_packs_screen.dart index c2fc4c2..93d275c 100644 --- a/lib/features/starter_packs/presentation/actor_starter_packs_screen.dart +++ b/lib/features/starter_packs/presentation/actor_starter_packs_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_animate/flutter_animate.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import 'package:lazurite/core/l10n/l10n.dart'; import 'package:lazurite/core/theme/animation_tokens.dart'; import 'package:lazurite/core/theme/animation_utils.dart'; import 'package:lazurite/features/starter_packs/cubit/actor_starter_packs_cubit.dart'; @@ -40,11 +41,11 @@ class _ActorStarterPacksView extends StatelessWidget { final isOwnProfile = currentUserDid != null && currentUserDid == actor; return Scaffold( - appBar: AppBar(title: const Text('Starter Packs')), + appBar: AppBar(title: Text(context.l10n.labelStarterPacks)), floatingActionButton: isOwnProfile ? FloatingActionButton( onPressed: () => context.push('/create-starter-pack'), - tooltip: 'Create starter pack', + tooltip: context.l10n.labelCreateStarterPack, child: const Icon(Icons.add), ).animateIfAllowed( context, @@ -65,11 +66,11 @@ class _ActorStarterPacksView extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Text(state.errorMessage ?? 'Failed to load starter packs'), + Text(state.errorMessage ?? context.l10n.errorFailedToLoadStarterPacks), const SizedBox(height: 12), FilledButton( onPressed: () => context.read().load(actor: actor), - child: const Text('Retry'), + child: Text(context.l10n.buttonRetry), ), ], ), @@ -77,7 +78,7 @@ class _ActorStarterPacksView extends StatelessWidget { } if (state.starterPacks.isEmpty) { - return const Center(child: Text('No starter packs yet')); + return Center(child: Text(context.l10n.messageNoStarterPacksYet)); } return AnimatedRefreshIndicator( diff --git a/lib/features/starter_packs/presentation/create_edit_starter_pack_screen.dart b/lib/features/starter_packs/presentation/create_edit_starter_pack_screen.dart index d2a959a..a2b1c5a 100644 --- a/lib/features/starter_packs/presentation/create_edit_starter_pack_screen.dart +++ b/lib/features/starter_packs/presentation/create_edit_starter_pack_screen.dart @@ -4,10 +4,11 @@ import 'package:bluesky/app_bsky_feed_defs.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import 'package:lazurite/core/l10n/l10n.dart'; +import 'package:lazurite/core/theme/theme_extensions.dart'; import 'package:lazurite/features/starter_packs/bloc/starter_pack_bloc.dart'; import 'package:lazurite/features/starter_packs/data/starter_pack_repository.dart'; import 'package:lazurite/features/typeahead/data/typeahead_repository.dart'; -import 'package:lazurite/core/theme/theme_extensions.dart'; import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; /// Full-screen form for creating a new starter pack. @@ -127,7 +128,7 @@ class _CreateStarterPackScreenState extends State { } else if (state.status == StarterPackStatus.error) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(state.errorMessage ?? 'Failed to create starter pack'), + content: Text(state.errorMessage ?? context.l10n.errorFailedToCreateStarterPack), behavior: SnackBarBehavior.floating, ), ); @@ -139,7 +140,7 @@ class _CreateStarterPackScreenState extends State { return Scaffold( appBar: AppBar( - title: const Text('New Starter Pack'), + title: Text(context.l10n.labelNewStarterPack), actions: [ if (isCreating) const Padding( @@ -147,7 +148,7 @@ class _CreateStarterPackScreenState extends State { child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)), ) else - TextButton(onPressed: _canSave ? _save : null, child: const Text('Create')), + TextButton(onPressed: _canSave ? _save : null, child: Text(context.l10n.buttonCreate)), ], ), body: ListView( @@ -155,10 +156,10 @@ class _CreateStarterPackScreenState extends State { children: [ TextField( controller: _nameController, - decoration: const InputDecoration( - labelText: 'Name', - border: OutlineInputBorder(), - helperText: 'Required, max 50 characters', + decoration: InputDecoration( + labelText: context.l10n.labelName, + border: const OutlineInputBorder(), + helperText: context.l10n.formatValidationRequiredMaxCharacters(50), ), maxLength: 50, textCapitalization: TextCapitalization.sentences, @@ -167,7 +168,10 @@ class _CreateStarterPackScreenState extends State { const SizedBox(height: 16), TextField( controller: _descController, - decoration: const InputDecoration(labelText: 'Description (optional)', border: OutlineInputBorder()), + decoration: InputDecoration( + labelText: context.l10n.labelDescriptionOptional, + border: const OutlineInputBorder(), + ), maxLength: 300, maxLines: 3, textCapitalization: TextCapitalization.sentences, @@ -192,12 +196,12 @@ class _CreateStarterPackScreenState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Members', style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600)), + Text(context.l10n.labelMembers, style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600)), const SizedBox(height: 8), TextField( controller: _searchController, decoration: InputDecoration( - hintText: 'Search for people to add', + hintText: context.l10n.messageSearchPeopleToAddPlaceholder, prefixIcon: const Icon(Icons.search), suffixIcon: _isSearching ? const Padding( @@ -278,9 +282,12 @@ class _CreateStarterPackScreenState extends State { children: [ Row( children: [ - Text('Feeds', style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600)), + Text(context.l10n.labelFeeds, style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600)), const SizedBox(width: 8), - Text('(up to 3)', style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant)), + Text( + context.l10n.labelUpToThree, + style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), + ), ], ), const SizedBox(height: 8), @@ -308,7 +315,7 @@ class _CreateStarterPackScreenState extends State { OutlinedButton.icon( onPressed: isCreating ? null : _showFeedPicker, icon: const Icon(Icons.add), - label: const Text('Add feed'), + label: Text(context.l10n.buttonAddFeed), ), ], ); @@ -340,7 +347,7 @@ class _FeedPickerSheetState extends State<_FeedPickerSheet> { final feeds = await widget.starterPackRepository.getSuggestedFeeds(limit: 50); if (mounted) setState(() => _feeds = feeds); } catch (e) { - if (mounted) setState(() => _error = 'Failed to load feeds'); + if (mounted) setState(() => _error = context.l10n.errorFailedToLoadFeeds); } } @@ -360,7 +367,7 @@ class _FeedPickerSheetState extends State<_FeedPickerSheet> { padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), child: Row( children: [ - Text('Select a feed', style: context.textTheme.titleMedium), + Text(context.l10n.labelSelectFeed, style: context.textTheme.titleMedium), const Spacer(), IconButton(icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context)), ], diff --git a/lib/features/starter_packs/presentation/starter_pack_detail_screen.dart b/lib/features/starter_packs/presentation/starter_pack_detail_screen.dart index 670ba44..0b48e13 100644 --- a/lib/features/starter_packs/presentation/starter_pack_detail_screen.dart +++ b/lib/features/starter_packs/presentation/starter_pack_detail_screen.dart @@ -5,11 +5,12 @@ import 'package:flutter/material.dart' hide ListView; import 'package:flutter/material.dart' as material show ListView; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import 'package:lazurite/core/l10n/l10n.dart'; +import 'package:lazurite/core/theme/theme_extensions.dart'; import 'package:lazurite/features/starter_packs/bloc/starter_pack_bloc.dart'; import 'package:lazurite/features/starter_packs/data/starter_pack_repository.dart'; import 'package:lazurite/shared/presentation/helpers/navigation_helpers.dart'; import 'package:lazurite/shared/utils/format_utils.dart'; -import 'package:lazurite/core/theme/theme_extensions.dart'; class StarterPackDetailScreen extends StatelessWidget { const StarterPackDetailScreen({super.key, required this.packUri}); @@ -50,7 +51,7 @@ class _StarterPackDetailView extends StatelessWidget { if (state.followedCount != null) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Followed ${state.followedCount} member${state.followedCount == 1 ? '' : 's'}'), + content: Text(context.l10n.formatFollowedMemberCount(state.followedCount!)), behavior: SnackBarBehavior.floating, ), ); @@ -75,7 +76,11 @@ class _StarterPackDetailView extends StatelessWidget { floating: true, pinned: true, snap: true, - title: Text(pack != null ? ((pack.record['name'] as String?) ?? 'Starter Pack') : 'Starter Pack'), + title: Text( + pack != null + ? ((pack.record['name'] as String?) ?? context.l10n.labelStarterPack) + : context.l10n.labelStarterPack, + ), actions: [ if (state.isMutating) const Padding( @@ -85,9 +90,9 @@ class _StarterPackDetailView extends StatelessWidget { else if (isCreator && pack != null) PopupMenuButton<_PackAction>( onSelected: (action) => _handleAction(context, action, state, pack), - itemBuilder: (_) => const [ - PopupMenuItem(value: _PackAction.edit, child: Text('Edit')), - PopupMenuItem(value: _PackAction.delete, child: Text('Delete')), + itemBuilder: (_) => [ + PopupMenuItem(value: _PackAction.edit, child: Text(context.l10n.buttonEdit)), + PopupMenuItem(value: _PackAction.delete, child: Text(context.l10n.buttonDelete)), ], ), ], @@ -102,13 +107,13 @@ class _StarterPackDetailView extends StatelessWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - Text(state.errorMessage ?? 'Failed to load starter pack'), + Text(state.errorMessage ?? context.l10n.errorFailedToLoadStarterPack), const SizedBox(height: 12), FilledButton( onPressed: () => context.read().add( StarterPackRequested(starterPackUri: state.packUri!), ), - child: const Text('Retry'), + child: Text(context.l10n.buttonRetry), ), ], ), @@ -167,16 +172,14 @@ class _StarterPackDetailView extends StatelessWidget { final confirmed = await showDialog( context: context, builder: (_) => AlertDialog( - title: const Text('Delete starter pack'), - content: const Text( - 'This will permanently delete this starter pack and its backing list. This cannot be undone.', - ), + title: Text(context.l10n.dialogDeleteStarterPackTitle), + content: Text(context.l10n.dialogDeleteStarterPackContent), actions: [ - TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('Cancel')), + TextButton(onPressed: () => Navigator.pop(context, false), child: Text(context.l10n.buttonCancel)), FilledButton( style: FilledButton.styleFrom(backgroundColor: context.colorScheme.error), onPressed: () => Navigator.pop(context, true), - child: const Text('Delete'), + child: Text(context.l10n.buttonDelete), ), ], ), @@ -258,7 +261,7 @@ class _EditStarterPackDialogState extends State<_EditStarterPackDialog> { final colorScheme = context.colorScheme; return AlertDialog( - title: const Text('Edit starter pack'), + title: Text(context.l10n.labelEditStarterPack), content: SizedBox( width: double.maxFinite, child: SingleChildScrollView( @@ -268,20 +271,23 @@ class _EditStarterPackDialogState extends State<_EditStarterPackDialog> { children: [ TextField( controller: _nameController, - decoration: const InputDecoration(labelText: 'Name', border: OutlineInputBorder()), + decoration: InputDecoration(labelText: context.l10n.labelName, border: const OutlineInputBorder()), maxLength: 50, textCapitalization: TextCapitalization.sentences, ), const SizedBox(height: 12), TextField( controller: _descController, - decoration: const InputDecoration(labelText: 'Description (optional)', border: OutlineInputBorder()), + decoration: InputDecoration( + labelText: context.l10n.labelDescriptionOptional, + border: const OutlineInputBorder(), + ), maxLength: 300, maxLines: 3, textCapitalization: TextCapitalization.sentences, ), const SizedBox(height: 12), - Text('Feeds', style: context.textTheme.labelLarge), + Text(context.l10n.labelFeeds, style: context.textTheme.labelLarge), const SizedBox(height: 8), for (final feed in _feeds) ListTile( @@ -300,14 +306,21 @@ class _EditStarterPackDialogState extends State<_EditStarterPackDialog> { ), ), if (_feeds.length < 3) - TextButton.icon(onPressed: _showFeedPicker, icon: const Icon(Icons.add), label: const Text('Add feed')), + TextButton.icon( + onPressed: _showFeedPicker, + icon: const Icon(Icons.add), + label: Text(context.l10n.buttonAddFeed), + ), ], ), ), ), actions: [ - TextButton(onPressed: () => Navigator.pop(context), child: const Text('Cancel')), - FilledButton(onPressed: _nameController.text.trim().isEmpty ? null : _save, child: const Text('Save')), + TextButton(onPressed: () => Navigator.pop(context), child: Text(context.l10n.buttonCancel)), + FilledButton( + onPressed: _nameController.text.trim().isEmpty ? null : _save, + child: Text(context.l10n.buttonSave), + ), ], ); } @@ -338,7 +351,7 @@ class _FeedPickerSheetState extends State<_FeedPickerSheet> { final feeds = await widget.starterPackRepository.getSuggestedFeeds(limit: 50); if (mounted) setState(() => _feeds = feeds); } catch (e) { - if (mounted) setState(() => _error = 'Failed to load feeds'); + if (mounted) setState(() => _error = context.l10n.errorFailedToLoadFeeds); } } @@ -358,7 +371,7 @@ class _FeedPickerSheetState extends State<_FeedPickerSheet> { padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), child: Row( children: [ - Text('Select a feed', style: context.textTheme.titleMedium), + Text(context.l10n.labelSelectFeed, style: context.textTheme.titleMedium), const Spacer(), IconButton(icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context)), ], @@ -412,7 +425,7 @@ class _StarterPackContent extends StatelessWidget { final colorScheme = context.colorScheme; final textTheme = context.textTheme; - final name = (pack.record['name'] as String?) ?? 'Starter Pack'; + final name = (pack.record['name'] as String?) ?? context.l10n.labelStarterPack; final description = pack.record['description'] as String?; return Padding( @@ -466,10 +479,11 @@ class _StarterPackContent extends StatelessWidget { Row( children: [ if (pack.joinedWeekCount != null) ...[ - _buildStatChip(context, pack.joinedWeekCount!, 'joined this week'), + _buildStatChip(context, pack.joinedWeekCount!, context.l10n.labelJoinedThisWeek), const SizedBox(width: 12), ], - if (pack.joinedAllTimeCount != null) _buildStatChip(context, pack.joinedAllTimeCount!, 'total joined'), + if (pack.joinedAllTimeCount != null) + _buildStatChip(context, pack.joinedAllTimeCount!, context.l10n.labelTotalJoined), ], ), const SizedBox(height: 16), @@ -516,19 +530,22 @@ class _StarterPackContent extends StatelessWidget { children: [ Row( children: [ - Text('Members', style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)), + Text(context.l10n.labelMembers, style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)), if (refListUri != null) ...[ const Spacer(), TextButton( onPressed: () => context.push('/list?uri=${Uri.encodeComponent(refListUri.toString())}'), - child: const Text('See all'), + child: Text(context.l10n.buttonSeeAll), ), ], ], ), const SizedBox(height: 8), if (sample.isEmpty) - Text('No members', style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant)) + Text( + context.l10n.messageNoMembers, + style: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant), + ) else SizedBox( height: 72, @@ -586,7 +603,7 @@ class _StarterPackContent extends StatelessWidget { icon: state.isFollowingAll ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.group_add_outlined), - label: Text(state.isFollowingAll ? 'Following…' : 'Follow all'), + label: Text(state.isFollowingAll ? context.l10n.buttonFollowingInProgress : context.l10n.buttonFollowAll), ), ), ], @@ -602,7 +619,7 @@ class _StarterPackContent extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Recommended Feeds', style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)), + Text(context.l10n.labelRecommendedFeeds, style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700)), const SizedBox(height: 8), for (final feed in feeds.take(3)) ListTile( diff --git a/lib/features/starter_packs/presentation/widgets/starter_pack_card.dart b/lib/features/starter_packs/presentation/widgets/starter_pack_card.dart index 0040453..6057d3e 100644 --- a/lib/features/starter_packs/presentation/widgets/starter_pack_card.dart +++ b/lib/features/starter_packs/presentation/widgets/starter_pack_card.dart @@ -1,8 +1,9 @@ import 'package:bluesky/app_bsky_graph_defs.dart'; import 'package:flutter/material.dart'; +import 'package:lazurite/core/l10n/l10n.dart'; +import 'package:lazurite/core/theme/theme_extensions.dart'; import 'package:lazurite/shared/presentation/widgets/profile_avatar.dart'; import 'package:lazurite/shared/utils/format_utils.dart'; -import 'package:lazurite/core/theme/theme_extensions.dart'; class StarterPackCard extends StatelessWidget { const StarterPackCard({super.key, required this.pack, this.onTap}); @@ -15,7 +16,7 @@ class StarterPackCard extends StatelessWidget { final colorScheme = context.colorScheme; final textTheme = context.textTheme; - final name = (pack.record['name'] as String?) ?? 'Starter Pack'; + final name = (pack.record['name'] as String?) ?? context.l10n.labelStarterPack; final memberCount = pack.listItemCount; final joinedWeek = pack.joinedWeekCount; final joinedAll = pack.joinedAllTimeCount; @@ -50,7 +51,7 @@ class StarterPackCard extends StatelessWidget { overflow: TextOverflow.ellipsis, ), Text( - 'by @${pack.creator.handle}', + context.l10n.formatListByHandle(pack.creator.handle), style: textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), ), ], @@ -63,9 +64,9 @@ class StarterPackCard extends StatelessWidget { Wrap( spacing: 16, children: [ - if (memberCount != null) _buildStat(context, memberCount, 'members'), - if (joinedWeek != null) _buildStat(context, joinedWeek, 'joined this week'), - if (joinedAll != null) _buildStat(context, joinedAll, 'joined total'), + if (memberCount != null) _buildStat(context, memberCount, context.l10n.labelMembers.toLowerCase()), + if (joinedWeek != null) _buildStat(context, joinedWeek, context.l10n.labelJoinedThisWeek), + if (joinedAll != null) _buildStat(context, joinedAll, context.l10n.labelJoinedTotal), ], ), ], diff --git a/test/features/feed/presentation/post_interactions_sheet_test.dart b/test/features/feed/presentation/post_interactions_sheet_test.dart index 2abc446..ccfd5c6 100644 --- a/test/features/feed/presentation/post_interactions_sheet_test.dart +++ b/test/features/feed/presentation/post_interactions_sheet_test.dart @@ -157,7 +157,7 @@ void main() { await tester.pump(); await tester.pump(); - await tester.tap(find.text('1 Reposts')); + await tester.tap(find.text('1 Repost')); await tester.pump(); await tester.pump();