From 175b3faf748cd2cc3c360df7baec622086eb3abc Mon Sep 17 00:00:00 2001 From: TEE0207 Date: Fri, 12 Jun 2026 09:44:15 +0100 Subject: [PATCH] feat(mobile): allow customizing the quick-access symbol bar Closes #1661 --- .../ephemeral/flutter_native_integration.env | 12 + mobile-app/l10n.yaml | 2 +- mobile-app/lib/app/app.dart | 2 + mobile-app/lib/app/app.locator.dart | 2 + mobile-app/lib/app/app.router.dart | 109 +- mobile-app/lib/l10n/app_en.arb | 522 ++++++++++ mobile-app/lib/l10n/app_es.arb | 328 +++++++ mobile-app/lib/l10n/app_localizations.dart | 927 ++++++++++++++++++ mobile-app/lib/l10n/app_localizations_en.dart | 534 ++++++++++ mobile-app/lib/l10n/app_localizations_es.dart | 534 ++++++++++ mobile-app/lib/l10n/app_localizations_pt.dart | 534 ++++++++++ mobile-app/lib/l10n/app_pt.arb | 328 +++++++ mobile-app/lib/main.dart | 5 + mobile-app/lib/models/symbol_set_model.dart | 257 +++++ .../authentication_service.dart | 39 +- .../firebase/remote_config_service.dart | 2 +- .../daily_challenge_notification_service.dart | 21 +- .../lib/service/learn/learn_service.dart | 24 +- .../navigation/quick_actions_service.dart | 24 +- .../lib/service/podcast/download_service.dart | 6 +- .../service/podcast/notification_service.dart | 12 +- .../lib/service/symbol_bar_service.dart | 270 +++++ .../ui/viewmodels/symbol_bar_viewmodel.dart | 167 ++++ .../ui/views/code_radio/code_radio_view.dart | 4 +- .../learn/block/block_template_viewmodel.dart | 7 +- .../templates/dialogue/dialogue_view.dart | 3 +- .../learn/block/templates/grid/grid_view.dart | 5 +- .../learn/block/templates/link/link_view.dart | 9 +- .../learn/block/templates/list/list_view.dart | 5 +- .../views/learn/challenge/challenge_view.dart | 26 +- .../learn/challenge/challenge_viewmodel.dart | 21 +- .../templates/english/english_view.dart | 19 +- .../multiple_choice/multiple_choice_view.dart | 27 +- .../multiple_choice_viewmodel.dart | 5 +- .../python-project/python_project_view.dart | 13 +- .../python_project_viewmodel.dart | 15 +- .../templates/python/python_view.dart | 17 +- .../templates/python/python_viewmodel.dart | 5 +- .../challenge/templates/quiz/quiz_view.dart | 15 +- .../templates/quiz/quiz_viewmodel.dart | 9 +- .../templates/review/review_view.dart | 10 +- .../challenge/templates/template_view.dart | 10 +- .../ui/views/learn/chapter/chapter_view.dart | 19 +- .../daily_challenge/daily_challenge_view.dart | 30 +- .../daily_challenge_viewmodel.dart | 11 +- .../ui/views/learn/landing/landing_view.dart | 112 +-- .../learn/landing/landing_viewmodel.dart | 22 +- .../learn/superblock/superblock_view.dart | 7 +- .../ui/views/learn/utils/challenge_utils.dart | 12 +- .../widgets/challenge_widgets/symbol_bar.dart | 98 +- .../learn/widgets/daily_challenge_card.dart | 29 +- .../views/learn/widgets/example_editor.dart | 3 +- .../learn/widgets/explanation_widget.dart | 3 +- .../learn/widgets/hint/hint_widget_view.dart | 7 +- .../learn/widgets/quiz_audio_player.dart | 15 +- .../ui/views/learn/widgets/quiz_widget.dart | 9 +- .../learn/widgets/transcript_widget.dart | 3 +- .../lib/ui/views/login/native_login_view.dart | 112 ++- .../views/news/html_handler/html_handler.dart | 20 +- .../news_bookmark_feed_view.dart | 2 +- .../news-image-viewer/news_image_view.dart | 3 +- .../news_tutorial_viewmodel.dart | 5 +- .../views/podcast/episode/episode_view.dart | 13 +- .../podcast/widgets/podcast_title_widget.dart | 17 +- .../lib/ui/views/profile/profile_view.dart | 87 +- .../lib/ui/views/settings/settings_view.dart | 16 + .../widgets/symbol_settings_widget.dart | 474 +++++++++ .../drawer_widget/drawer_widget_view.dart | 24 +- .../drawer_widget_viewmodel.dart | 9 +- .../lib/ui/widgets/setup_dialog_ui.dart | 51 +- mobile-app/pubspec.lock | 12 +- mobile-app/pubspec.yaml | 5 +- mobile-app/test/helpers/test_helpers.dart | 9 + ...y_challenge_notification_service_test.dart | 7 + .../services/symbol_bar_service_test.dart | 394 ++++++++ .../symbol_bar_service_test.mocks.dart | 203 ++++ .../widget/daily_challenge_card_test.dart | 3 + .../widget/daily_challenge_view_test.dart | 3 + mobile-app/untranslated.json | 1 + package-lock.json | 3 +- 80 files changed, 6217 insertions(+), 522 deletions(-) create mode 100644 mobile-app/ios/Flutter/ephemeral/flutter_native_integration.env create mode 100644 mobile-app/lib/models/symbol_set_model.dart create mode 100644 mobile-app/lib/service/symbol_bar_service.dart create mode 100644 mobile-app/lib/ui/viewmodels/symbol_bar_viewmodel.dart create mode 100644 mobile-app/lib/ui/views/settings/widgets/symbol_settings_widget.dart create mode 100644 mobile-app/test/services/symbol_bar_service_test.dart create mode 100644 mobile-app/test/services/symbol_bar_service_test.mocks.dart create mode 100644 mobile-app/untranslated.json diff --git a/mobile-app/ios/Flutter/ephemeral/flutter_native_integration.env b/mobile-app/ios/Flutter/ephemeral/flutter_native_integration.env new file mode 100644 index 000000000..3fc42dc30 --- /dev/null +++ b/mobile-app/ios/Flutter/ephemeral/flutter_native_integration.env @@ -0,0 +1,12 @@ +FLUTTER_ROOT=C:\Users\Manuel\develop\flutter +FLUTTER_APPLICATION_PATH=C:\Users\Manuel\Desktop\mobile\mobile-app +FLUTTER_FRAMEWORK_SWIFT_PACKAGE_PATH=C:\Users\Manuel\Desktop\mobile\mobile-app\ios\Flutter\ephemeral\Packages\.packages\FlutterFramework +COCOAPODS_PARALLEL_CODE_SIGN=true +FLUTTER_TARGET=lib\main.dart +FLUTTER_BUILD_DIR=build +FLUTTER_BUILD_NAME=7.5.2 +FLUTTER_BUILD_NUMBER=71502 +DART_OBFUSCATION=false +TRACK_WIDGET_CREATION=true +TREE_SHAKE_ICONS=false +PACKAGE_CONFIG=.dart_tool/package_config.json diff --git a/mobile-app/l10n.yaml b/mobile-app/l10n.yaml index 3228b2fa9..dbee21ea9 100644 --- a/mobile-app/l10n.yaml +++ b/mobile-app/l10n.yaml @@ -1,3 +1,3 @@ arb-dir: lib/l10n nullable-getter: false -synthetic-package: false +untranslated-messages-file: untranslated.json diff --git a/mobile-app/lib/app/app.dart b/mobile-app/lib/app/app.dart index e48ccd570..aac8311c2 100644 --- a/mobile-app/lib/app/app.dart +++ b/mobile-app/lib/app/app.dart @@ -6,6 +6,7 @@ import 'package:freecodecamp/service/firebase/remote_config_service.dart'; import 'package:freecodecamp/service/learn/learn_file_service.dart'; import 'package:freecodecamp/service/learn/learn_offline_service.dart'; import 'package:freecodecamp/service/learn/learn_service.dart'; +import 'package:freecodecamp/service/symbol_bar_service.dart'; import 'package:freecodecamp/service/learn/daily_challenge_service.dart'; import 'package:freecodecamp/service/learn/daily_challenge_notification_service.dart'; import 'package:freecodecamp/service/locale_service.dart'; @@ -85,6 +86,7 @@ import 'package:stacked_services/stacked_services.dart'; LazySingleton(classType: LocaleService), LazySingleton(classType: DioService), LazySingleton(classType: NewsApiService), + LazySingleton(classType: SymbolBarService), ], logger: StackedLogger(), ) diff --git a/mobile-app/lib/app/app.locator.dart b/mobile-app/lib/app/app.locator.dart index 203878340..fd2813677 100644 --- a/mobile-app/lib/app/app.locator.dart +++ b/mobile-app/lib/app/app.locator.dart @@ -29,6 +29,7 @@ import '../service/news/bookmark_service.dart'; import '../service/podcast/download_service.dart'; import '../service/podcast/notification_service.dart'; import '../service/podcast/podcasts_service.dart'; +import '../service/symbol_bar_service.dart'; final locator = StackedLocator.instance; @@ -62,4 +63,5 @@ Future setupLocator({ locator.registerLazySingleton(() => LocaleService()); locator.registerLazySingleton(() => DioService()); locator.registerLazySingleton(() => NewsApiService()); + locator.registerLazySingleton(() => SymbolBarService()); } diff --git a/mobile-app/lib/app/app.router.dart b/mobile-app/lib/app/app.router.dart index b02317648..b2062bc84 100644 --- a/mobile-app/lib/app/app.router.dart +++ b/mobile-app/lib/app/app.router.dart @@ -5,13 +5,14 @@ // ************************************************************************** // ignore_for_file: no_leading_underscores_for_library_prefixes +import 'package:flutter/foundation.dart' as _i22; import 'package:flutter/material.dart' as _i21; import 'package:flutter/material.dart'; -import 'package:freecodecamp/models/learn/curriculum_model.dart' as _i25; +import 'package:freecodecamp/models/learn/curriculum_model.dart' as _i26; import 'package:freecodecamp/models/news/bookmarked_tutorial_model.dart' - as _i24; -import 'package:freecodecamp/models/podcasts/episodes_model.dart' as _i22; -import 'package:freecodecamp/models/podcasts/podcasts_model.dart' as _i23; + as _i25; +import 'package:freecodecamp/models/podcasts/episodes_model.dart' as _i23; +import 'package:freecodecamp/models/podcasts/podcasts_model.dart' as _i24; import 'package:freecodecamp/ui/views/code_radio/code_radio_view.dart' as _i10; import 'package:freecodecamp/ui/views/learn/challenge/templates/template_view.dart' as _i11; @@ -44,7 +45,7 @@ import 'package:freecodecamp/ui/views/settings/delete-account/delete_account_vie as _i19; import 'package:freecodecamp/ui/views/settings/settings_view.dart' as _i18; import 'package:stacked/stacked.dart' as _i1; -import 'package:stacked_services/stacked_services.dart' as _i26; +import 'package:stacked_services/stacked_services.dart' as _i27; class Routes { static const newsViewHandlerView = '/news-view-handler-view'; @@ -361,11 +362,11 @@ class EpisodeViewArguments { required this.podcast, }); - final _i21.Key? key; + final _i22.Key? key; - final _i22.Episodes episode; + final _i23.Episodes episode; - final _i23.Podcasts podcast; + final _i24.Podcasts podcast; @override String toString() { @@ -393,7 +394,7 @@ class NewsTutorialViewArguments { required this.slug, }); - final _i21.Key? key; + final _i22.Key? key; final String refId; @@ -422,9 +423,9 @@ class NewsBookmarkTutorialViewArguments { required this.tutorial, }); - final _i21.Key? key; + final _i22.Key? key; - final _i24.BookmarkedTutorial tutorial; + final _i25.BookmarkedTutorial tutorial; @override String toString() { @@ -455,7 +456,7 @@ class NewsFeedViewArguments { this.subject = '', }); - final _i21.Key? key; + final _i22.Key? key; final String tagSlug; @@ -508,7 +509,7 @@ class NewsAuthorViewArguments { required this.authorSlug, }); - final _i21.Key? key; + final _i22.Key? key; final String authorSlug; @@ -536,7 +537,7 @@ class NewsImageViewArguments { required this.isDataUrl, }); - final _i21.Key? key; + final _i22.Key? key; final String imgUrl; @@ -569,9 +570,9 @@ class ChallengeTemplateViewArguments { this.challengeDate, }); - final _i21.Key? key; + final _i22.Key? key; - final _i25.Block block; + final _i26.Block block; final String challengeId; @@ -607,7 +608,7 @@ class ChapterViewArguments { required this.superBlockName, }); - final _i21.Key? key; + final _i22.Key? key; final String superBlockDashedName; @@ -641,11 +642,11 @@ class ChapterBlockViewArguments { required this.blocks, }); - final _i21.Key? key; + final _i22.Key? key; final String moduleName; - final List<_i25.Block> blocks; + final List<_i26.Block> blocks; @override String toString() { @@ -672,7 +673,7 @@ class NativeLoginViewArguments { this.fromButton = false, }); - final _i21.Key? key; + final _i22.Key? key; final bool fromButton; @@ -701,7 +702,7 @@ class SuperBlockViewArguments { required this.hasInternet, }); - final _i21.Key? key; + final _i22.Key? key; final String superBlockDashedName; @@ -732,7 +733,7 @@ class SuperBlockViewArguments { } } -extension NavigatorStateExtension on _i26.NavigationService { +extension NavigatorStateExtension on _i27.NavigationService { Future navigateToNewsViewHandlerView([ int? routerId, bool preventDuplicates = true, @@ -762,9 +763,9 @@ extension NavigatorStateExtension on _i26.NavigationService { } Future navigateToEpisodeView({ - _i21.Key? key, - required _i22.Episodes episode, - required _i23.Podcasts podcast, + _i22.Key? key, + required _i23.Episodes episode, + required _i24.Podcasts podcast, int? routerId, bool preventDuplicates = true, Map? parameters, @@ -781,7 +782,7 @@ extension NavigatorStateExtension on _i26.NavigationService { } Future navigateToNewsTutorialView({ - _i21.Key? key, + _i22.Key? key, required String refId, required String slug, int? routerId, @@ -800,8 +801,8 @@ extension NavigatorStateExtension on _i26.NavigationService { } Future navigateToNewsBookmarkTutorialView({ - _i21.Key? key, - required _i24.BookmarkedTutorial tutorial, + _i22.Key? key, + required _i25.BookmarkedTutorial tutorial, int? routerId, bool preventDuplicates = true, Map? parameters, @@ -818,7 +819,7 @@ extension NavigatorStateExtension on _i26.NavigationService { } Future navigateToNewsFeedView({ - _i21.Key? key, + _i22.Key? key, String tagSlug = '', String authorId = '', bool fromAuthor = false, @@ -849,7 +850,7 @@ extension NavigatorStateExtension on _i26.NavigationService { } Future navigateToNewsAuthorView({ - _i21.Key? key, + _i22.Key? key, required String authorSlug, int? routerId, bool preventDuplicates = true, @@ -866,7 +867,7 @@ extension NavigatorStateExtension on _i26.NavigationService { } Future navigateToNewsImageView({ - _i21.Key? key, + _i22.Key? key, required String imgUrl, required bool isDataUrl, int? routerId, @@ -899,8 +900,8 @@ extension NavigatorStateExtension on _i26.NavigationService { } Future navigateToChallengeTemplateView({ - _i21.Key? key, - required _i25.Block block, + _i22.Key? key, + required _i26.Block block, required String challengeId, DateTime? challengeDate, int? routerId, @@ -922,7 +923,7 @@ extension NavigatorStateExtension on _i26.NavigationService { } Future navigateToChapterView({ - _i21.Key? key, + _i22.Key? key, required String superBlockDashedName, required String superBlockName, int? routerId, @@ -943,9 +944,9 @@ extension NavigatorStateExtension on _i26.NavigationService { } Future navigateToChapterBlockView({ - _i21.Key? key, + _i22.Key? key, required String moduleName, - required List<_i25.Block> blocks, + required List<_i26.Block> blocks, int? routerId, bool preventDuplicates = true, Map? parameters, @@ -990,7 +991,7 @@ extension NavigatorStateExtension on _i26.NavigationService { } Future navigateToNativeLoginView({ - _i21.Key? key, + _i22.Key? key, bool fromButton = false, int? routerId, bool preventDuplicates = true, @@ -1007,7 +1008,7 @@ extension NavigatorStateExtension on _i26.NavigationService { } Future navigateToSuperBlockView({ - _i21.Key? key, + _i22.Key? key, required String superBlockDashedName, required String superBlockName, required bool hasInternet, @@ -1100,9 +1101,9 @@ extension NavigatorStateExtension on _i26.NavigationService { } Future replaceWithEpisodeView({ - _i21.Key? key, - required _i22.Episodes episode, - required _i23.Podcasts podcast, + _i22.Key? key, + required _i23.Episodes episode, + required _i24.Podcasts podcast, int? routerId, bool preventDuplicates = true, Map? parameters, @@ -1119,7 +1120,7 @@ extension NavigatorStateExtension on _i26.NavigationService { } Future replaceWithNewsTutorialView({ - _i21.Key? key, + _i22.Key? key, required String refId, required String slug, int? routerId, @@ -1138,8 +1139,8 @@ extension NavigatorStateExtension on _i26.NavigationService { } Future replaceWithNewsBookmarkTutorialView({ - _i21.Key? key, - required _i24.BookmarkedTutorial tutorial, + _i22.Key? key, + required _i25.BookmarkedTutorial tutorial, int? routerId, bool preventDuplicates = true, Map? parameters, @@ -1156,7 +1157,7 @@ extension NavigatorStateExtension on _i26.NavigationService { } Future replaceWithNewsFeedView({ - _i21.Key? key, + _i22.Key? key, String tagSlug = '', String authorId = '', bool fromAuthor = false, @@ -1187,7 +1188,7 @@ extension NavigatorStateExtension on _i26.NavigationService { } Future replaceWithNewsAuthorView({ - _i21.Key? key, + _i22.Key? key, required String authorSlug, int? routerId, bool preventDuplicates = true, @@ -1204,7 +1205,7 @@ extension NavigatorStateExtension on _i26.NavigationService { } Future replaceWithNewsImageView({ - _i21.Key? key, + _i22.Key? key, required String imgUrl, required bool isDataUrl, int? routerId, @@ -1237,8 +1238,8 @@ extension NavigatorStateExtension on _i26.NavigationService { } Future replaceWithChallengeTemplateView({ - _i21.Key? key, - required _i25.Block block, + _i22.Key? key, + required _i26.Block block, required String challengeId, DateTime? challengeDate, int? routerId, @@ -1260,7 +1261,7 @@ extension NavigatorStateExtension on _i26.NavigationService { } Future replaceWithChapterView({ - _i21.Key? key, + _i22.Key? key, required String superBlockDashedName, required String superBlockName, int? routerId, @@ -1281,9 +1282,9 @@ extension NavigatorStateExtension on _i26.NavigationService { } Future replaceWithChapterBlockView({ - _i21.Key? key, + _i22.Key? key, required String moduleName, - required List<_i25.Block> blocks, + required List<_i26.Block> blocks, int? routerId, bool preventDuplicates = true, Map? parameters, @@ -1328,7 +1329,7 @@ extension NavigatorStateExtension on _i26.NavigationService { } Future replaceWithNativeLoginView({ - _i21.Key? key, + _i22.Key? key, bool fromButton = false, int? routerId, bool preventDuplicates = true, @@ -1345,7 +1346,7 @@ extension NavigatorStateExtension on _i26.NavigationService { } Future replaceWithSuperBlockView({ - _i21.Key? key, + _i22.Key? key, required String superBlockDashedName, required String superBlockName, required bool hasInternet, diff --git a/mobile-app/lib/l10n/app_en.arb b/mobile-app/lib/l10n/app_en.arb index 5f60b9d67..312955313 100644 --- a/mobile-app/lib/l10n/app_en.arb +++ b/mobile-app/lib/l10n/app_en.arb @@ -153,6 +153,20 @@ "@passed": { "description": "learn > block > challenge > *complete challenge* > pass panel" }, + "review": "Review", + "@review": { + "description": "learn > chapter card label for review modules" + }, + "steps_complete": "{progress} Steps Complete", + "@steps_complete": { + "description": "learn > chapter card progress label. The progress placeholder is a completed/total value, for example 2/10.", + "placeholders": { + "progress": { + "type": "String", + "example": "2/10" + } + } + }, "hint": "Hint", "@hint": { "description": "learn > block > challenge > *fail challenge* > hint panel" @@ -550,6 +564,514 @@ "quincy_email_part_fifteen": "By the way, each Friday I send an email with 5 links about programming and computer science. I send these to about 4 million people. Would you like me to send this to you, too?", "quincy_email_confirm": "Yes Please", "quincy_email_no_thanks": "No Thanks", + "cancel": "Cancel", + "@cancel": { + "description": "generic cancel action" + }, + "leave": "Leave", + "@leave": { + "description": "generic leave action" + }, + "back": "Back", + "@back": { + "description": "generic back navigation label" + }, + "completed": "Completed", + "@completed": { + "description": "status label for a completed lesson or challenge" + }, + "start": "Start", + "@start": { + "description": "generic start action" + }, + "hide_steps": "Hide Steps", + "@hide_steps": { + "description": "learn > block button that collapses the steps list" + }, + "show_steps": "Show Steps", + "@show_steps": { + "description": "learn > block button that expands the steps list" + }, + "hide_challenges": "Hide Challenges", + "@hide_challenges": { + "description": "daily challenges view button that collapses a month of challenges" + }, + "show_challenges": "Show Challenges", + "@show_challenges": { + "description": "daily challenges view button that expands a month of challenges" + }, + "hide_tasks": "Hide Tasks", + "@hide_tasks": { + "description": "dialogue block button that collapses the task list" + }, + "show_tasks": "Show Tasks", + "@show_tasks": { + "description": "dialogue block button that expands the task list" + }, + "copied_to_clipboard": "{subject} copied to clipboard!", + "@copied_to_clipboard": { + "description": "snackbar shown after copying inline code from a tutorial", + "placeholders": { + "subject": { + "type": "String", + "example": "console.log()" + } + } + }, + "email_error": "Email Error", + "login_email_error_subject": "Error logging in to mobile app", + "login_email_error_message": "Please email the below error to {supportEmail}:\n\n{error}\n\n{stackTrace}", + "@login_email_error_message": { + "placeholders": { + "supportEmail": { + "type": "String" + }, + "error": { + "type": "String" + }, + "stackTrace": { + "type": "String" + } + } + }, + "quick_action_daily_challenges": "Daily Challenges", + "code_radio": "CODE RADIO", + "@code_radio": { + "description": "drawer and quick action label for Code Radio" + }, + "report_an_issue": "REPORT AN ISSUE", + "@report_an_issue": { + "description": "drawer action that opens the GitHub issue form" + }, + "login_terms": "By continuing, you indicate that you have read and agree to freeCodeCamp.org's {termsOfService} and {privacyPolicy}.", + "@login_terms": { + "description": "login view legal copy. The termsOfService and privacyPolicy placeholders are displayed as tappable links.", + "placeholders": { + "termsOfService": { + "type": "String", + "example": "Terms of Service" + }, + "privacyPolicy": { + "type": "String", + "example": "Privacy Policy" + } + } + }, + "terms_of_service": "Terms of Service", + "@terms_of_service": { + "description": "login view link text for the terms page" + }, + "privacy_policy": "Privacy Policy", + "@privacy_policy": { + "description": "login view link text for the privacy policy page" + }, + "no_blocks_available": "No blocks available right now.", + "coming_soon": "Coming Soon", + "temporarily_unavailable": "Temporarily unavailable, come back soon.", + "@temporarily_unavailable": { + "description": "snackbar shown when a curriculum item is temporarily disabled" + }, + "not_available_web": "Not available use the web version", + "@not_available_web": { + "description": "snackbar shown when a curriculum item is only available on the web" + }, + "stage_core": "Recommended curriculum (still in beta):", + "@stage_core": { + "description": "learn landing heading for recommended curriculum stage" + }, + "stage_english": "Learn English for Developers:", + "@stage_english": { + "description": "learn landing heading for English curriculum stage" + }, + "stage_spanish": "Learn Professional Spanish:", + "@stage_spanish": { + "description": "learn landing heading for Spanish curriculum stage" + }, + "stage_chinese": "Learn Professional Chinese:", + "@stage_chinese": { + "description": "learn landing heading for Chinese curriculum stage" + }, + "stage_extra": "Prepare for the developer interview job search:", + "@stage_extra": { + "description": "learn landing heading for interview and job search preparation stage" + }, + "stage_professional": "Professional certifications:", + "@stage_professional": { + "description": "learn landing heading for professional certification stage" + }, + "login_coming_soon": "Login will soon be available!", + "coming_soon_web": "Coming soon - use the web version", + "daily_coding_challenges": "Daily Coding Challenges", + "daily_challenge_empty": "No challenges available at the moment.", + "daily_challenge_completed": "Today's challenge completed!", + "daily_challenge_countdown_label": "Countdown timer. Next challenge in {timeLeft}", + "@daily_challenge_countdown_label": { + "placeholders": { + "timeLeft": { + "type": "String" + } + } + }, + "daily_challenge_next_in": "Next challenge in: {timeLeft}", + "@daily_challenge_next_in": { + "placeholders": { + "timeLeft": { + "type": "String" + } + } + }, + "daily_challenge_view_past_tooltip": "View past daily challenges", + "daily_challenge_view_past": "View past challenges", + "daily_challenge_today": "Today's challenge", + "daily_challenge_prompt": "Do you have the skills to complete this challenge?", + "daily_challenge_start_tooltip": "Start the daily challenge now", + "daily_challenge_go_to_challenge": "Go to challenge", + "daily_challenge_start": "Start the challenge", + "daily_challenge_completed_semantics": "Daily challenge completed. View past challenges.", + "daily_challenge_card_semantics": "Daily challenge card", + "daily_challenge_month_description": "Explore the daily coding challenges for {monthYear}. Stay motivated and keep your learning streak alive!", + "@daily_challenge_month_description": { + "description": "daily challenges view description for a month group", + "placeholders": { + "monthYear": { + "type": "String", + "example": "January 2026" + } + } + }, + "daily_challenge_item_title": "Challenge {challengeNumber}: {challengeTitle}", + "@daily_challenge_item_title": { + "description": "daily challenges view list item title", + "placeholders": { + "challengeNumber": { + "type": "int", + "example": "1" + }, + "challengeTitle": { + "type": "String", + "example": "Build a Palindrome Checker" + } + } + }, + "daily_challenge_item_completed_label": "Challenge {challengeNumber}: {challengeTitle}, completed", + "@daily_challenge_item_completed_label": { + "description": "accessibility label for a completed daily challenge list item", + "placeholders": { + "challengeNumber": { + "type": "int", + "example": "1" + }, + "challengeTitle": { + "type": "String", + "example": "Build a Palindrome Checker" + } + } + }, + "daily_challenge_item_not_completed_label": "Challenge {challengeNumber}: {challengeTitle}, not completed", + "@daily_challenge_item_not_completed_label": { + "description": "accessibility label for an incomplete daily challenge list item", + "placeholders": { + "challengeNumber": { + "type": "int", + "example": "1" + }, + "challengeTitle": { + "type": "String", + "example": "Build a Palindrome Checker" + } + } + }, + "new_daily_challenge_available": "New Daily Challenge Available! 🧩", + "@new_daily_challenge_available": { + "description": "daily challenge notification title" + }, + "daily_challenge_notification_body": "A fresh coding challenge is waiting for you. Ready to solve it?", + "@daily_challenge_notification_body": { + "description": "daily challenge notification body" + }, + "daily_challenge_notifications": "Daily Challenge Notifications", + "@daily_challenge_notifications": { + "description": "Android notification channel name for daily challenge notifications" + }, + "daily_challenge_notifications_description": "Notifications for new daily coding challenges", + "@daily_challenge_notifications_description": { + "description": "Android notification channel description for daily challenge notifications" + }, + "download_complete": "Download complete", + "download_episode": "Download episode", + "notification_channel_description": "Notifications for completed podcast episode downloads", + "@notification_channel_description": { + "description": "Android notification channel description for podcast download notifications" + }, + "podcast_pause_episode": "Pause episode", + "@podcast_pause_episode": { + "description": "accessibility label for the podcast pause button" + }, + "podcast_play_episode": "Play episode", + "@podcast_play_episode": { + "description": "accessibility label for the podcast play button" + }, + "user": "User", + "@user": { + "description": "generic fallback username" + }, + "profile_location": "Location", + "@profile_location": { + "description": "profile metadata label" + }, + "profile_x": "X", + "@profile_x": { + "description": "profile social link label for X/Twitter" + }, + "profile_github": "GitHub", + "@profile_github": { + "description": "profile social link label for GitHub" + }, + "profile_linkedin": "LinkedIn", + "@profile_linkedin": { + "description": "profile social link label for LinkedIn" + }, + "profile_website": "Website", + "@profile_website": { + "description": "profile website link label" + }, + "profile_joined": "Joined", + "@profile_joined": { + "description": "profile metadata label for join date" + }, + "profile_points_label": "Points", + "@profile_points_label": { + "description": "profile metadata label for point total" + }, + "profile_longest_streak_label": "Longest Streak", + "@profile_longest_streak_label": { + "description": "profile metadata label for longest coding streak" + }, + "profile_current_streak_label": "Current Streak", + "@profile_current_streak_label": { + "description": "profile metadata label for current coding streak" + }, + "profile_about": "About", + "@profile_about": { + "description": "profile section heading" + }, + "profile_no_certifications": "You have not yet earned any certifications.", + "@profile_no_certifications": { + "description": "profile empty state when the user has no certifications" + }, + "cert_responsive_web_design": "Responsive Web Design Certification", + "@cert_responsive_web_design": { + "description": "profile certification title" + }, + "cert_javascript_algorithms": "JavaScript Algorithms and Data Structures Certification", + "@cert_javascript_algorithms": { + "description": "profile certification title" + }, + "cert_front_end_libraries": "Front End Development Libraries Certification", + "@cert_front_end_libraries": { + "description": "profile certification title" + }, + "cert_data_visualization": "Data Visualization Certification", + "@cert_data_visualization": { + "description": "profile certification title" + }, + "cert_back_end_development_apis": "Back End Development and APIs Certification", + "@cert_back_end_development_apis": { + "description": "profile certification title" + }, + "cert_quality_assurance": "Quality Assurance Certification", + "@cert_quality_assurance": { + "description": "profile certification title" + }, + "cert_information_security": "Information Security Certification", + "@cert_information_security": { + "description": "profile certification title" + }, + "cert_scientific_computing_python": "Scientific Computing with Python Certification", + "@cert_scientific_computing_python": { + "description": "profile certification title" + }, + "cert_data_analysis_python": "Data Analysis with Python Certification", + "@cert_data_analysis_python": { + "description": "profile certification title" + }, + "cert_machine_learning_python": "Machine Learning with Python Certification", + "@cert_machine_learning_python": { + "description": "profile certification title" + }, + "cert_relational_database": "Relational Database Certification", + "@cert_relational_database": { + "description": "profile certification title" + }, + "cert_college_algebra_python": "College Algebra with Python", + "@cert_college_algebra_python": { + "description": "profile certification title" + }, + "cert_foundational_csharp": "Foundational C# with Microsoft", + "@cert_foundational_csharp": { + "description": "profile certification title" + }, + "cert_front_end": "Front End Certification", + "@cert_front_end": { + "description": "profile legacy certification title" + }, + "cert_back_end": "Back End Certification", + "@cert_back_end": { + "description": "profile legacy certification title" + }, + "cert_information_security_quality_assurance": "Information Security and Quality Assurance Certification", + "@cert_information_security_quality_assurance": { + "description": "profile legacy certification title" + }, + "cert_full_stack": "Full Stack Certification", + "@cert_full_stack": { + "description": "profile legacy certification title" + }, + "challenge_card_description": "Description", + "challenge_card_video": "Video", + "challenge_card_watch_video": "Watch the Video", + "challenge_card_transcript": "Transcript", + "challenge_card_scene": "Scene", + "challenge_card_listen_audio": "Listen to the Audio", + "challenge_card_assignments": "Assignments", + "challenge_card_explanation": "Explanation", + "challenge_card_fill_blank": "Fill in the Blank", + "challenge_card_feedback": "Feedback", + "challenge_card_lesson": "Lesson", + "tap_to_expand": "Tap to expand", + "tests": "Tests", + "quiz_leave_title": "Are you sure?", + "quiz_leave_message": "Do you want to leave this quiz? Your progress will be lost.", + "quiz_question": "Question", + "quiz_question_number": "Question {questionNumber}", + "@quiz_question_number": { + "placeholders": { + "questionNumber": { + "type": "int" + } + } + }, + "quiz_correct": "Correct!", + "quiz_incorrect": "Incorrect!", + "quiz_unanswered_questions": "The following questions are unanswered: {questions}. You must answer all questions.", + "@quiz_unanswered_questions": { + "placeholders": { + "questions": { + "type": "String" + } + } + }, + "quiz_passed": "You have {correctQuestionsCount} out of {totalQuestions} questions correct. You have passed.", + "@quiz_passed": { + "placeholders": { + "correctQuestionsCount": { + "type": "int" + }, + "totalQuestions": { + "type": "int" + } + } + }, + "quiz_failed": "You have {correctQuestionsCount} out of {totalQuestions} questions correct. You did not pass.", + "@quiz_failed": { + "placeholders": { + "correctQuestionsCount": { + "type": "int" + }, + "totalQuestions": { + "type": "int" + } + } + }, + "some_answers_are_incorrect": "Some answers are incorrect.", + "show_transcript": "Show transcript", + "audio_load_error": "Error loading audio", + "project_link_hint": "ex: https://replit.com/@camperbot/hello", + "project_link_invalid": "Please enter a valid link.", + "project_link_own_work": "Remember to submit your own work.", + "project_link_insecure": "An unsecure (http) URL cannot be used.", + "project_link_public_url": "Remember to submit a publicly visible app URL.", + "forum_create_post": "Create a post", + "forum_help_description": "If you've already tried the Read-Search-Ask method, then you can try asking for help on the freeCodeCamp forum.", + "forum_confirm_description": "You must confirm the following statements before you can submit your post to the forum.", + "forum_tell_us_heading": "Tell us what's happening:", + "forum_describe_issue": "Describe your issue in detail here.", + "forum_code_so_far": "Your code so far", + "forum_mobile_info": "Your mobile information:", + "forum_challenge": "Challenge:", + "forum_challenge_link": "Link to the challenge:", + "forum_warning": "WARNING", + "forum_code_too_long": "The challenge seed code and/or your solution exceeded the maximum length we can port over from the challenge.", + "forum_additional_step": "You will need to take an additional step here so the code you wrote presents in an easy to read format.", + "forum_copy_editor_code": "Please copy/paste all the editor code showing in the challenge from where you just linked.", + "forum_replace_code": "Replace these two sentences with your copied code.\nPlease leave the ``` line above and the ``` line below,\nbecause they allow your code to properly format in the post.", + "forum_tried": "I have tried the ", + "forum_read_search_ask": "Read-Search-Ask", + "forum_method": " method", + "forum_searched_for": "I have searched for ", + "forum_similar_questions": "similar questions that have already been answered on the forum", + "forum_enter_more_chars": "Please enter at least {characterCount} more characters.", + "@forum_enter_more_chars": { + "placeholders": { + "characterCount": { + "type": "int" + } + } + }, + "forum_issue_hint": "Describe your issue in detail here...", + "forum_help_dialog_prefix": "If you've already tried the ", + "forum_help_dialog_suffix": " method, then you can ask for help on the freeCodeCamp forum.", + "forum_before_post": "Before making a new post please ", + "forum_check_answered": "check if your question has already been answered on the forum", + "go_to_next_challenge": "Go to Next Challenge", + "check_answers": "Check Answers", + "tutorial_written_by_colon": "Written by: {author}", + "@tutorial_written_by_colon": { + "placeholders": { + "author": { + "type": "String" + } + } + }, + "error_loading_chapters": "Error loading chapters: {error} {stackTrace}", + "@error_loading_chapters": { + "placeholders": { + "error": { + "type": "String" + }, + "stackTrace": { + "type": "String" + } + } + }, + "syntax_error_line": "There is a syntax error on line {line}. Please check for missing brackets, quotes, or other syntax issues.", + "@syntax_error_line": { + "placeholders": { + "line": { + "type": "int" + } + } + }, + "syntax_error_general": "There is a syntax error in your code. Please check for missing brackets, quotes, or other syntax issues.", + "unknown_challenge": "Unknown Challenge, info : {challengeType}", + "@unknown_challenge": { + "placeholders": { + "challengeType": { + "type": "String" + } + } + }, + "challenge_error": "Error: {error}\n{stackTrace}", + "@challenge_error": { + "placeholders": { + "error": { + "type": "String" + }, + "stackTrace": { + "type": "String" + } + } + }, "error": "Something went wrong!", "error_two": "Error", "error_three": "Oops! Something went wrong. Please try again in a moment.", diff --git a/mobile-app/lib/l10n/app_es.arb b/mobile-app/lib/l10n/app_es.arb index e1bee2790..f3699bea1 100644 --- a/mobile-app/lib/l10n/app_es.arb +++ b/mobile-app/lib/l10n/app_es.arb @@ -153,6 +153,15 @@ "@passed": { "description": "learn > block > challenge > *complete challenge* > pass panel" }, + "review": "Review", + "steps_complete": "{progress} Steps Complete", + "@steps_complete": { + "placeholders": { + "progress": { + "type": "String" + } + } + }, "hint": "Hint", "@hint": { "description": "learn > block > challenge > *fail challenge* > hint panel" @@ -409,6 +418,10 @@ "@settings_privacy_policy_description": { "description": "settings view" }, + "coderadio_unable_to_load": "Unable to load coderadio \n please try again.", + "@coderadio_unable_to_load": { + "description": "coderadio view" + }, "coderadio_next_song": "Next Song", "@coderadio_next_song": { "description": "coderadio view (only on Android)" @@ -546,6 +559,321 @@ "quincy_email_part_fifteen": "By the way, each Friday I send an email with 5 links about programming and computer science. I send these to about 4 million people. Would you like me to send this to you, too?", "quincy_email_confirm": "Yes Please", "quincy_email_no_thanks": "No Thanks", + "cancel": "Cancel", + "leave": "Leave", + "back": "Back", + "completed": "Completed", + "start": "Start", + "hide_steps": "Hide Steps", + "show_steps": "Show Steps", + "hide_challenges": "Hide Challenges", + "show_challenges": "Show Challenges", + "hide_tasks": "Hide Tasks", + "show_tasks": "Show Tasks", + "copied_to_clipboard": "{subject} copied to clipboard!", + "@copied_to_clipboard": { + "placeholders": { + "subject": { + "type": "String" + } + } + }, + "email_error": "Email Error", + "login_email_error_subject": "Error logging in to mobile app", + "login_email_error_message": "Please email the below error to {supportEmail}:\n\n{error}\n\n{stackTrace}", + "@login_email_error_message": { + "placeholders": { + "supportEmail": { + "type": "String" + }, + "error": { + "type": "String" + }, + "stackTrace": { + "type": "String" + } + } + }, + "quick_action_daily_challenges": "Daily Challenges", + "code_radio": "CODE RADIO", + "report_an_issue": "REPORT AN ISSUE", + "login_terms": "By continuing, you indicate that you have read and agree to freeCodeCamp.org's {termsOfService} and {privacyPolicy}.", + "@login_terms": { + "placeholders": { + "termsOfService": { + "type": "String" + }, + "privacyPolicy": { + "type": "String" + } + } + }, + "terms_of_service": "Terms of Service", + "privacy_policy": "Privacy Policy", + "no_blocks_available": "No blocks available right now.", + "coming_soon": "Coming Soon", + "temporarily_unavailable": "Temporarily unavailable, come back soon.", + "not_available_web": "Not available use the web version", + "stage_core": "Recommended curriculum (still in beta):", + "stage_english": "Learn English for Developers:", + "stage_spanish": "Learn Professional Spanish:", + "stage_chinese": "Learn Professional Chinese:", + "stage_extra": "Prepare for the developer interview job search:", + "stage_professional": "Professional certifications:", + "login_coming_soon": "Login will soon be available!", + "coming_soon_web": "Coming soon - use the web version", + "daily_coding_challenges": "Daily Coding Challenges", + "daily_challenge_empty": "No challenges available at the moment.", + "daily_challenge_completed": "Today's challenge completed!", + "daily_challenge_countdown_label": "Countdown timer. Next challenge in {timeLeft}", + "@daily_challenge_countdown_label": { + "placeholders": { + "timeLeft": { + "type": "String" + } + } + }, + "daily_challenge_next_in": "Next challenge in: {timeLeft}", + "@daily_challenge_next_in": { + "placeholders": { + "timeLeft": { + "type": "String" + } + } + }, + "daily_challenge_view_past_tooltip": "View past daily challenges", + "daily_challenge_view_past": "View past challenges", + "daily_challenge_today": "Today's challenge", + "daily_challenge_prompt": "Do you have the skills to complete this challenge?", + "daily_challenge_start_tooltip": "Start the daily challenge now", + "daily_challenge_go_to_challenge": "Go to challenge", + "daily_challenge_start": "Start the challenge", + "daily_challenge_completed_semantics": "Daily challenge completed. View past challenges.", + "daily_challenge_card_semantics": "Daily challenge card", + "daily_challenge_month_description": "Explore the daily coding challenges for {monthYear}. Stay motivated and keep your learning streak alive!", + "@daily_challenge_month_description": { + "placeholders": { + "monthYear": { + "type": "String" + } + } + }, + "daily_challenge_item_title": "Challenge {challengeNumber}: {challengeTitle}", + "@daily_challenge_item_title": { + "placeholders": { + "challengeNumber": { + "type": "int" + }, + "challengeTitle": { + "type": "String" + } + } + }, + "daily_challenge_item_completed_label": "Challenge {challengeNumber}: {challengeTitle}, completed", + "@daily_challenge_item_completed_label": { + "placeholders": { + "challengeNumber": { + "type": "int" + }, + "challengeTitle": { + "type": "String" + } + } + }, + "daily_challenge_item_not_completed_label": "Challenge {challengeNumber}: {challengeTitle}, not completed", + "@daily_challenge_item_not_completed_label": { + "placeholders": { + "challengeNumber": { + "type": "int" + }, + "challengeTitle": { + "type": "String" + } + } + }, + "new_daily_challenge_available": "New Daily Challenge Available! 🧩", + "daily_challenge_notification_body": "A fresh coding challenge is waiting for you. Ready to solve it?", + "daily_challenge_notifications": "Daily Challenge Notifications", + "daily_challenge_notifications_description": "Notifications for new daily coding challenges", + "download_complete": "Download complete", + "download_episode": "Download episode", + "notification_channel_description": "Notifications for completed podcast episode downloads", + "podcast_pause_episode": "Pause episode", + "podcast_play_episode": "Play episode", + "user": "User", + "profile_location": "Location", + "profile_x": "X", + "profile_github": "GitHub", + "profile_linkedin": "LinkedIn", + "profile_website": "Website", + "profile_joined": "Joined", + "profile_points_label": "Points", + "profile_longest_streak_label": "Longest Streak", + "profile_current_streak_label": "Current Streak", + "profile_about": "About", + "profile_no_certifications": "You have not yet earned any certifications.", + "cert_responsive_web_design": "Responsive Web Design Certification", + "cert_javascript_algorithms": "JavaScript Algorithms and Data Structures Certification", + "cert_front_end_libraries": "Front End Development Libraries Certification", + "cert_data_visualization": "Data Visualization Certification", + "cert_back_end_development_apis": "Back End Development and APIs Certification", + "cert_quality_assurance": "Quality Assurance Certification", + "cert_information_security": "Information Security Certification", + "cert_scientific_computing_python": "Scientific Computing with Python Certification", + "cert_data_analysis_python": "Data Analysis with Python Certification", + "cert_machine_learning_python": "Machine Learning with Python Certification", + "cert_relational_database": "Relational Database Certification", + "cert_college_algebra_python": "College Algebra with Python", + "cert_foundational_csharp": "Foundational C# with Microsoft", + "cert_front_end": "Front End Certification", + "cert_back_end": "Back End Certification", + "cert_information_security_quality_assurance": "Information Security and Quality Assurance Certification", + "cert_full_stack": "Full Stack Certification", + "challenge_card_description": "Description", + "challenge_card_video": "Video", + "challenge_card_watch_video": "Watch the Video", + "challenge_card_transcript": "Transcript", + "challenge_card_scene": "Scene", + "challenge_card_listen_audio": "Listen to the Audio", + "challenge_card_assignments": "Assignments", + "challenge_card_explanation": "Explanation", + "challenge_card_fill_blank": "Fill in the Blank", + "challenge_card_feedback": "Feedback", + "challenge_card_lesson": "Lesson", + "tap_to_expand": "Tap to expand", + "tests": "Tests", + "quiz_leave_title": "Are you sure?", + "quiz_leave_message": "Do you want to leave this quiz? Your progress will be lost.", + "quiz_question": "Question", + "quiz_question_number": "Question {questionNumber}", + "@quiz_question_number": { + "placeholders": { + "questionNumber": { + "type": "int" + } + } + }, + "quiz_correct": "Correct!", + "quiz_incorrect": "Incorrect!", + "quiz_unanswered_questions": "The following questions are unanswered: {questions}. You must answer all questions.", + "@quiz_unanswered_questions": { + "placeholders": { + "questions": { + "type": "String" + } + } + }, + "quiz_passed": "You have {correctQuestionsCount} out of {totalQuestions} questions correct. You have passed.", + "@quiz_passed": { + "placeholders": { + "correctQuestionsCount": { + "type": "int" + }, + "totalQuestions": { + "type": "int" + } + } + }, + "quiz_failed": "You have {correctQuestionsCount} out of {totalQuestions} questions correct. You did not pass.", + "@quiz_failed": { + "placeholders": { + "correctQuestionsCount": { + "type": "int" + }, + "totalQuestions": { + "type": "int" + } + } + }, + "some_answers_are_incorrect": "Some answers are incorrect.", + "show_transcript": "Show transcript", + "audio_load_error": "Error loading audio", + "project_link_hint": "ex: https://replit.com/@camperbot/hello", + "project_link_invalid": "Please enter a valid link.", + "project_link_own_work": "Remember to submit your own work.", + "project_link_insecure": "An unsecure (http) URL cannot be used.", + "project_link_public_url": "Remember to submit a publicly visible app URL.", + "forum_create_post": "Create a post", + "forum_help_description": "If you've already tried the Read-Search-Ask method, then you can try asking for help on the freeCodeCamp forum.", + "forum_confirm_description": "You must confirm the following statements before you can submit your post to the forum.", + "forum_tell_us_heading": "Tell us what's happening:", + "forum_describe_issue": "Describe your issue in detail here.", + "forum_code_so_far": "Your code so far", + "forum_mobile_info": "Your mobile information:", + "forum_challenge": "Challenge:", + "forum_challenge_link": "Link to the challenge:", + "forum_warning": "WARNING", + "forum_code_too_long": "The challenge seed code and/or your solution exceeded the maximum length we can port over from the challenge.", + "forum_additional_step": "You will need to take an additional step here so the code you wrote presents in an easy to read format.", + "forum_copy_editor_code": "Please copy/paste all the editor code showing in the challenge from where you just linked.", + "forum_replace_code": "Replace these two sentences with your copied code.\nPlease leave the ``` line above and the ``` line below,\nbecause they allow your code to properly format in the post.", + "forum_tried": "I have tried the ", + "forum_read_search_ask": "Read-Search-Ask", + "forum_method": " method", + "forum_searched_for": "I have searched for ", + "forum_similar_questions": "similar questions that have already been answered on the forum", + "forum_enter_more_chars": "Please enter at least {characterCount} more characters.", + "@forum_enter_more_chars": { + "placeholders": { + "characterCount": { + "type": "int" + } + } + }, + "forum_issue_hint": "Describe your issue in detail here...", + "forum_help_dialog_prefix": "If you've already tried the ", + "forum_help_dialog_suffix": " method, then you can ask for help on the freeCodeCamp forum.", + "forum_before_post": "Before making a new post please ", + "forum_check_answered": "check if your question has already been answered on the forum", + "go_to_next_challenge": "Go to Next Challenge", + "check_answers": "Check Answers", + "tutorial_written_by_colon": "Written by: {author}", + "@tutorial_written_by_colon": { + "placeholders": { + "author": { + "type": "String" + } + } + }, + "error_loading_chapters": "Error loading chapters: {error} {stackTrace}", + "@error_loading_chapters": { + "placeholders": { + "error": { + "type": "String" + }, + "stackTrace": { + "type": "String" + } + } + }, + "syntax_error_line": "There is a syntax error on line {line}. Please check for missing brackets, quotes, or other syntax issues.", + "@syntax_error_line": { + "placeholders": { + "line": { + "type": "int" + } + } + }, + "syntax_error_general": "There is a syntax error in your code. Please check for missing brackets, quotes, or other syntax issues.", + "unknown_challenge": "Unknown Challenge, info : {challengeType}", + "@unknown_challenge": { + "placeholders": { + "challengeType": { + "type": "String" + } + } + }, + "challenge_error": "Error: {error}\n{stackTrace}", + "@challenge_error": { + "placeholders": { + "error": { + "type": "String" + }, + "stackTrace": { + "type": "String" + } + } + }, "error": "Something went wrong!", "error_two": "Error", "error_three": "Oops! Something went wrong. Please try again in a moment.", diff --git a/mobile-app/lib/l10n/app_localizations.dart b/mobile-app/lib/l10n/app_localizations.dart index cb4df3d84..a969b1d74 100644 --- a/mobile-app/lib/l10n/app_localizations.dart +++ b/mobile-app/lib/l10n/app_localizations.dart @@ -328,6 +328,18 @@ abstract class AppLocalizations { /// **'Passed'** String get passed; + /// learn > chapter card label for review modules + /// + /// In en, this message translates to: + /// **'Review'** + String get review; + + /// learn > chapter card progress label. The progress placeholder is a completed/total value, for example 2/10. + /// + /// In en, this message translates to: + /// **'{progress} Steps Complete'** + String steps_complete(String progress); + /// learn > block > challenge > *fail challenge* > hint panel /// /// In en, this message translates to: @@ -910,6 +922,921 @@ abstract class AppLocalizations { /// **'No Thanks'** String get quincy_email_no_thanks; + /// generic cancel action + /// + /// In en, this message translates to: + /// **'Cancel'** + String get cancel; + + /// generic leave action + /// + /// In en, this message translates to: + /// **'Leave'** + String get leave; + + /// generic back navigation label + /// + /// In en, this message translates to: + /// **'Back'** + String get back; + + /// status label for a completed lesson or challenge + /// + /// In en, this message translates to: + /// **'Completed'** + String get completed; + + /// generic start action + /// + /// In en, this message translates to: + /// **'Start'** + String get start; + + /// learn > block button that collapses the steps list + /// + /// In en, this message translates to: + /// **'Hide Steps'** + String get hide_steps; + + /// learn > block button that expands the steps list + /// + /// In en, this message translates to: + /// **'Show Steps'** + String get show_steps; + + /// daily challenges view button that collapses a month of challenges + /// + /// In en, this message translates to: + /// **'Hide Challenges'** + String get hide_challenges; + + /// daily challenges view button that expands a month of challenges + /// + /// In en, this message translates to: + /// **'Show Challenges'** + String get show_challenges; + + /// dialogue block button that collapses the task list + /// + /// In en, this message translates to: + /// **'Hide Tasks'** + String get hide_tasks; + + /// dialogue block button that expands the task list + /// + /// In en, this message translates to: + /// **'Show Tasks'** + String get show_tasks; + + /// snackbar shown after copying inline code from a tutorial + /// + /// In en, this message translates to: + /// **'{subject} copied to clipboard!'** + String copied_to_clipboard(String subject); + + /// No description provided for @email_error. + /// + /// In en, this message translates to: + /// **'Email Error'** + String get email_error; + + /// No description provided for @login_email_error_subject. + /// + /// In en, this message translates to: + /// **'Error logging in to mobile app'** + String get login_email_error_subject; + + /// No description provided for @login_email_error_message. + /// + /// In en, this message translates to: + /// **'Please email the below error to {supportEmail}:\n\n{error}\n\n{stackTrace}'** + String login_email_error_message( + String supportEmail, String error, String stackTrace); + + /// No description provided for @quick_action_daily_challenges. + /// + /// In en, this message translates to: + /// **'Daily Challenges'** + String get quick_action_daily_challenges; + + /// drawer and quick action label for Code Radio + /// + /// In en, this message translates to: + /// **'CODE RADIO'** + String get code_radio; + + /// drawer action that opens the GitHub issue form + /// + /// In en, this message translates to: + /// **'REPORT AN ISSUE'** + String get report_an_issue; + + /// login view legal copy. The termsOfService and privacyPolicy placeholders are displayed as tappable links. + /// + /// In en, this message translates to: + /// **'By continuing, you indicate that you have read and agree to freeCodeCamp.org\'s {termsOfService} and {privacyPolicy}.'** + String login_terms(String termsOfService, String privacyPolicy); + + /// login view link text for the terms page + /// + /// In en, this message translates to: + /// **'Terms of Service'** + String get terms_of_service; + + /// login view link text for the privacy policy page + /// + /// In en, this message translates to: + /// **'Privacy Policy'** + String get privacy_policy; + + /// No description provided for @no_blocks_available. + /// + /// In en, this message translates to: + /// **'No blocks available right now.'** + String get no_blocks_available; + + /// No description provided for @coming_soon. + /// + /// In en, this message translates to: + /// **'Coming Soon'** + String get coming_soon; + + /// snackbar shown when a curriculum item is temporarily disabled + /// + /// In en, this message translates to: + /// **'Temporarily unavailable, come back soon.'** + String get temporarily_unavailable; + + /// snackbar shown when a curriculum item is only available on the web + /// + /// In en, this message translates to: + /// **'Not available use the web version'** + String get not_available_web; + + /// learn landing heading for recommended curriculum stage + /// + /// In en, this message translates to: + /// **'Recommended curriculum (still in beta):'** + String get stage_core; + + /// learn landing heading for English curriculum stage + /// + /// In en, this message translates to: + /// **'Learn English for Developers:'** + String get stage_english; + + /// learn landing heading for Spanish curriculum stage + /// + /// In en, this message translates to: + /// **'Learn Professional Spanish:'** + String get stage_spanish; + + /// learn landing heading for Chinese curriculum stage + /// + /// In en, this message translates to: + /// **'Learn Professional Chinese:'** + String get stage_chinese; + + /// learn landing heading for interview and job search preparation stage + /// + /// In en, this message translates to: + /// **'Prepare for the developer interview job search:'** + String get stage_extra; + + /// learn landing heading for professional certification stage + /// + /// In en, this message translates to: + /// **'Professional certifications:'** + String get stage_professional; + + /// No description provided for @login_coming_soon. + /// + /// In en, this message translates to: + /// **'Login will soon be available!'** + String get login_coming_soon; + + /// No description provided for @coming_soon_web. + /// + /// In en, this message translates to: + /// **'Coming soon - use the web version'** + String get coming_soon_web; + + /// No description provided for @daily_coding_challenges. + /// + /// In en, this message translates to: + /// **'Daily Coding Challenges'** + String get daily_coding_challenges; + + /// No description provided for @daily_challenge_empty. + /// + /// In en, this message translates to: + /// **'No challenges available at the moment.'** + String get daily_challenge_empty; + + /// No description provided for @daily_challenge_completed. + /// + /// In en, this message translates to: + /// **'Today\'s challenge completed!'** + String get daily_challenge_completed; + + /// No description provided for @daily_challenge_countdown_label. + /// + /// In en, this message translates to: + /// **'Countdown timer. Next challenge in {timeLeft}'** + String daily_challenge_countdown_label(String timeLeft); + + /// No description provided for @daily_challenge_next_in. + /// + /// In en, this message translates to: + /// **'Next challenge in: {timeLeft}'** + String daily_challenge_next_in(String timeLeft); + + /// No description provided for @daily_challenge_view_past_tooltip. + /// + /// In en, this message translates to: + /// **'View past daily challenges'** + String get daily_challenge_view_past_tooltip; + + /// No description provided for @daily_challenge_view_past. + /// + /// In en, this message translates to: + /// **'View past challenges'** + String get daily_challenge_view_past; + + /// No description provided for @daily_challenge_today. + /// + /// In en, this message translates to: + /// **'Today\'s challenge'** + String get daily_challenge_today; + + /// No description provided for @daily_challenge_prompt. + /// + /// In en, this message translates to: + /// **'Do you have the skills to complete this challenge?'** + String get daily_challenge_prompt; + + /// No description provided for @daily_challenge_start_tooltip. + /// + /// In en, this message translates to: + /// **'Start the daily challenge now'** + String get daily_challenge_start_tooltip; + + /// No description provided for @daily_challenge_go_to_challenge. + /// + /// In en, this message translates to: + /// **'Go to challenge'** + String get daily_challenge_go_to_challenge; + + /// No description provided for @daily_challenge_start. + /// + /// In en, this message translates to: + /// **'Start the challenge'** + String get daily_challenge_start; + + /// No description provided for @daily_challenge_completed_semantics. + /// + /// In en, this message translates to: + /// **'Daily challenge completed. View past challenges.'** + String get daily_challenge_completed_semantics; + + /// No description provided for @daily_challenge_card_semantics. + /// + /// In en, this message translates to: + /// **'Daily challenge card'** + String get daily_challenge_card_semantics; + + /// daily challenges view description for a month group + /// + /// In en, this message translates to: + /// **'Explore the daily coding challenges for {monthYear}. Stay motivated and keep your learning streak alive!'** + String daily_challenge_month_description(String monthYear); + + /// daily challenges view list item title + /// + /// In en, this message translates to: + /// **'Challenge {challengeNumber}: {challengeTitle}'** + String daily_challenge_item_title(int challengeNumber, String challengeTitle); + + /// accessibility label for a completed daily challenge list item + /// + /// In en, this message translates to: + /// **'Challenge {challengeNumber}: {challengeTitle}, completed'** + String daily_challenge_item_completed_label( + int challengeNumber, String challengeTitle); + + /// accessibility label for an incomplete daily challenge list item + /// + /// In en, this message translates to: + /// **'Challenge {challengeNumber}: {challengeTitle}, not completed'** + String daily_challenge_item_not_completed_label( + int challengeNumber, String challengeTitle); + + /// daily challenge notification title + /// + /// In en, this message translates to: + /// **'New Daily Challenge Available! 🧩'** + String get new_daily_challenge_available; + + /// daily challenge notification body + /// + /// In en, this message translates to: + /// **'A fresh coding challenge is waiting for you. Ready to solve it?'** + String get daily_challenge_notification_body; + + /// Android notification channel name for daily challenge notifications + /// + /// In en, this message translates to: + /// **'Daily Challenge Notifications'** + String get daily_challenge_notifications; + + /// Android notification channel description for daily challenge notifications + /// + /// In en, this message translates to: + /// **'Notifications for new daily coding challenges'** + String get daily_challenge_notifications_description; + + /// No description provided for @download_complete. + /// + /// In en, this message translates to: + /// **'Download complete'** + String get download_complete; + + /// No description provided for @download_episode. + /// + /// In en, this message translates to: + /// **'Download episode'** + String get download_episode; + + /// Android notification channel description for podcast download notifications + /// + /// In en, this message translates to: + /// **'Notifications for completed podcast episode downloads'** + String get notification_channel_description; + + /// accessibility label for the podcast pause button + /// + /// In en, this message translates to: + /// **'Pause episode'** + String get podcast_pause_episode; + + /// accessibility label for the podcast play button + /// + /// In en, this message translates to: + /// **'Play episode'** + String get podcast_play_episode; + + /// generic fallback username + /// + /// In en, this message translates to: + /// **'User'** + String get user; + + /// profile metadata label + /// + /// In en, this message translates to: + /// **'Location'** + String get profile_location; + + /// profile social link label for X/Twitter + /// + /// In en, this message translates to: + /// **'X'** + String get profile_x; + + /// profile social link label for GitHub + /// + /// In en, this message translates to: + /// **'GitHub'** + String get profile_github; + + /// profile social link label for LinkedIn + /// + /// In en, this message translates to: + /// **'LinkedIn'** + String get profile_linkedin; + + /// profile website link label + /// + /// In en, this message translates to: + /// **'Website'** + String get profile_website; + + /// profile metadata label for join date + /// + /// In en, this message translates to: + /// **'Joined'** + String get profile_joined; + + /// profile metadata label for point total + /// + /// In en, this message translates to: + /// **'Points'** + String get profile_points_label; + + /// profile metadata label for longest coding streak + /// + /// In en, this message translates to: + /// **'Longest Streak'** + String get profile_longest_streak_label; + + /// profile metadata label for current coding streak + /// + /// In en, this message translates to: + /// **'Current Streak'** + String get profile_current_streak_label; + + /// profile section heading + /// + /// In en, this message translates to: + /// **'About'** + String get profile_about; + + /// profile empty state when the user has no certifications + /// + /// In en, this message translates to: + /// **'You have not yet earned any certifications.'** + String get profile_no_certifications; + + /// profile certification title + /// + /// In en, this message translates to: + /// **'Responsive Web Design Certification'** + String get cert_responsive_web_design; + + /// profile certification title + /// + /// In en, this message translates to: + /// **'JavaScript Algorithms and Data Structures Certification'** + String get cert_javascript_algorithms; + + /// profile certification title + /// + /// In en, this message translates to: + /// **'Front End Development Libraries Certification'** + String get cert_front_end_libraries; + + /// profile certification title + /// + /// In en, this message translates to: + /// **'Data Visualization Certification'** + String get cert_data_visualization; + + /// profile certification title + /// + /// In en, this message translates to: + /// **'Back End Development and APIs Certification'** + String get cert_back_end_development_apis; + + /// profile certification title + /// + /// In en, this message translates to: + /// **'Quality Assurance Certification'** + String get cert_quality_assurance; + + /// profile certification title + /// + /// In en, this message translates to: + /// **'Information Security Certification'** + String get cert_information_security; + + /// profile certification title + /// + /// In en, this message translates to: + /// **'Scientific Computing with Python Certification'** + String get cert_scientific_computing_python; + + /// profile certification title + /// + /// In en, this message translates to: + /// **'Data Analysis with Python Certification'** + String get cert_data_analysis_python; + + /// profile certification title + /// + /// In en, this message translates to: + /// **'Machine Learning with Python Certification'** + String get cert_machine_learning_python; + + /// profile certification title + /// + /// In en, this message translates to: + /// **'Relational Database Certification'** + String get cert_relational_database; + + /// profile certification title + /// + /// In en, this message translates to: + /// **'College Algebra with Python'** + String get cert_college_algebra_python; + + /// profile certification title + /// + /// In en, this message translates to: + /// **'Foundational C# with Microsoft'** + String get cert_foundational_csharp; + + /// profile legacy certification title + /// + /// In en, this message translates to: + /// **'Front End Certification'** + String get cert_front_end; + + /// profile legacy certification title + /// + /// In en, this message translates to: + /// **'Back End Certification'** + String get cert_back_end; + + /// profile legacy certification title + /// + /// In en, this message translates to: + /// **'Information Security and Quality Assurance Certification'** + String get cert_information_security_quality_assurance; + + /// profile legacy certification title + /// + /// In en, this message translates to: + /// **'Full Stack Certification'** + String get cert_full_stack; + + /// No description provided for @challenge_card_description. + /// + /// In en, this message translates to: + /// **'Description'** + String get challenge_card_description; + + /// No description provided for @challenge_card_video. + /// + /// In en, this message translates to: + /// **'Video'** + String get challenge_card_video; + + /// No description provided for @challenge_card_watch_video. + /// + /// In en, this message translates to: + /// **'Watch the Video'** + String get challenge_card_watch_video; + + /// No description provided for @challenge_card_transcript. + /// + /// In en, this message translates to: + /// **'Transcript'** + String get challenge_card_transcript; + + /// No description provided for @challenge_card_scene. + /// + /// In en, this message translates to: + /// **'Scene'** + String get challenge_card_scene; + + /// No description provided for @challenge_card_listen_audio. + /// + /// In en, this message translates to: + /// **'Listen to the Audio'** + String get challenge_card_listen_audio; + + /// No description provided for @challenge_card_assignments. + /// + /// In en, this message translates to: + /// **'Assignments'** + String get challenge_card_assignments; + + /// No description provided for @challenge_card_explanation. + /// + /// In en, this message translates to: + /// **'Explanation'** + String get challenge_card_explanation; + + /// No description provided for @challenge_card_fill_blank. + /// + /// In en, this message translates to: + /// **'Fill in the Blank'** + String get challenge_card_fill_blank; + + /// No description provided for @challenge_card_feedback. + /// + /// In en, this message translates to: + /// **'Feedback'** + String get challenge_card_feedback; + + /// No description provided for @challenge_card_lesson. + /// + /// In en, this message translates to: + /// **'Lesson'** + String get challenge_card_lesson; + + /// No description provided for @tap_to_expand. + /// + /// In en, this message translates to: + /// **'Tap to expand'** + String get tap_to_expand; + + /// No description provided for @tests. + /// + /// In en, this message translates to: + /// **'Tests'** + String get tests; + + /// No description provided for @quiz_leave_title. + /// + /// In en, this message translates to: + /// **'Are you sure?'** + String get quiz_leave_title; + + /// No description provided for @quiz_leave_message. + /// + /// In en, this message translates to: + /// **'Do you want to leave this quiz? Your progress will be lost.'** + String get quiz_leave_message; + + /// No description provided for @quiz_question. + /// + /// In en, this message translates to: + /// **'Question'** + String get quiz_question; + + /// No description provided for @quiz_question_number. + /// + /// In en, this message translates to: + /// **'Question {questionNumber}'** + String quiz_question_number(int questionNumber); + + /// No description provided for @quiz_correct. + /// + /// In en, this message translates to: + /// **'Correct!'** + String get quiz_correct; + + /// No description provided for @quiz_incorrect. + /// + /// In en, this message translates to: + /// **'Incorrect!'** + String get quiz_incorrect; + + /// No description provided for @quiz_unanswered_questions. + /// + /// In en, this message translates to: + /// **'The following questions are unanswered: {questions}. You must answer all questions.'** + String quiz_unanswered_questions(String questions); + + /// No description provided for @quiz_passed. + /// + /// In en, this message translates to: + /// **'You have {correctQuestionsCount} out of {totalQuestions} questions correct. You have passed.'** + String quiz_passed(int correctQuestionsCount, int totalQuestions); + + /// No description provided for @quiz_failed. + /// + /// In en, this message translates to: + /// **'You have {correctQuestionsCount} out of {totalQuestions} questions correct. You did not pass.'** + String quiz_failed(int correctQuestionsCount, int totalQuestions); + + /// No description provided for @some_answers_are_incorrect. + /// + /// In en, this message translates to: + /// **'Some answers are incorrect.'** + String get some_answers_are_incorrect; + + /// No description provided for @show_transcript. + /// + /// In en, this message translates to: + /// **'Show transcript'** + String get show_transcript; + + /// No description provided for @audio_load_error. + /// + /// In en, this message translates to: + /// **'Error loading audio'** + String get audio_load_error; + + /// No description provided for @project_link_hint. + /// + /// In en, this message translates to: + /// **'ex: https://replit.com/@camperbot/hello'** + String get project_link_hint; + + /// No description provided for @project_link_invalid. + /// + /// In en, this message translates to: + /// **'Please enter a valid link.'** + String get project_link_invalid; + + /// No description provided for @project_link_own_work. + /// + /// In en, this message translates to: + /// **'Remember to submit your own work.'** + String get project_link_own_work; + + /// No description provided for @project_link_insecure. + /// + /// In en, this message translates to: + /// **'An unsecure (http) URL cannot be used.'** + String get project_link_insecure; + + /// No description provided for @project_link_public_url. + /// + /// In en, this message translates to: + /// **'Remember to submit a publicly visible app URL.'** + String get project_link_public_url; + + /// No description provided for @forum_create_post. + /// + /// In en, this message translates to: + /// **'Create a post'** + String get forum_create_post; + + /// No description provided for @forum_help_description. + /// + /// In en, this message translates to: + /// **'If you\'ve already tried the Read-Search-Ask method, then you can try asking for help on the freeCodeCamp forum.'** + String get forum_help_description; + + /// No description provided for @forum_confirm_description. + /// + /// In en, this message translates to: + /// **'You must confirm the following statements before you can submit your post to the forum.'** + String get forum_confirm_description; + + /// No description provided for @forum_tell_us_heading. + /// + /// In en, this message translates to: + /// **'Tell us what\'s happening:'** + String get forum_tell_us_heading; + + /// No description provided for @forum_describe_issue. + /// + /// In en, this message translates to: + /// **'Describe your issue in detail here.'** + String get forum_describe_issue; + + /// No description provided for @forum_code_so_far. + /// + /// In en, this message translates to: + /// **'Your code so far'** + String get forum_code_so_far; + + /// No description provided for @forum_mobile_info. + /// + /// In en, this message translates to: + /// **'Your mobile information:'** + String get forum_mobile_info; + + /// No description provided for @forum_challenge. + /// + /// In en, this message translates to: + /// **'Challenge:'** + String get forum_challenge; + + /// No description provided for @forum_challenge_link. + /// + /// In en, this message translates to: + /// **'Link to the challenge:'** + String get forum_challenge_link; + + /// No description provided for @forum_warning. + /// + /// In en, this message translates to: + /// **'WARNING'** + String get forum_warning; + + /// No description provided for @forum_code_too_long. + /// + /// In en, this message translates to: + /// **'The challenge seed code and/or your solution exceeded the maximum length we can port over from the challenge.'** + String get forum_code_too_long; + + /// No description provided for @forum_additional_step. + /// + /// In en, this message translates to: + /// **'You will need to take an additional step here so the code you wrote presents in an easy to read format.'** + String get forum_additional_step; + + /// No description provided for @forum_copy_editor_code. + /// + /// In en, this message translates to: + /// **'Please copy/paste all the editor code showing in the challenge from where you just linked.'** + String get forum_copy_editor_code; + + /// No description provided for @forum_replace_code. + /// + /// In en, this message translates to: + /// **'Replace these two sentences with your copied code.\nPlease leave the ``` line above and the ``` line below,\nbecause they allow your code to properly format in the post.'** + String get forum_replace_code; + + /// No description provided for @forum_tried. + /// + /// In en, this message translates to: + /// **'I have tried the '** + String get forum_tried; + + /// No description provided for @forum_read_search_ask. + /// + /// In en, this message translates to: + /// **'Read-Search-Ask'** + String get forum_read_search_ask; + + /// No description provided for @forum_method. + /// + /// In en, this message translates to: + /// **' method'** + String get forum_method; + + /// No description provided for @forum_searched_for. + /// + /// In en, this message translates to: + /// **'I have searched for '** + String get forum_searched_for; + + /// No description provided for @forum_similar_questions. + /// + /// In en, this message translates to: + /// **'similar questions that have already been answered on the forum'** + String get forum_similar_questions; + + /// No description provided for @forum_enter_more_chars. + /// + /// In en, this message translates to: + /// **'Please enter at least {characterCount} more characters.'** + String forum_enter_more_chars(int characterCount); + + /// No description provided for @forum_issue_hint. + /// + /// In en, this message translates to: + /// **'Describe your issue in detail here...'** + String get forum_issue_hint; + + /// No description provided for @forum_help_dialog_prefix. + /// + /// In en, this message translates to: + /// **'If you\'ve already tried the '** + String get forum_help_dialog_prefix; + + /// No description provided for @forum_help_dialog_suffix. + /// + /// In en, this message translates to: + /// **' method, then you can ask for help on the freeCodeCamp forum.'** + String get forum_help_dialog_suffix; + + /// No description provided for @forum_before_post. + /// + /// In en, this message translates to: + /// **'Before making a new post please '** + String get forum_before_post; + + /// No description provided for @forum_check_answered. + /// + /// In en, this message translates to: + /// **'check if your question has already been answered on the forum'** + String get forum_check_answered; + + /// No description provided for @go_to_next_challenge. + /// + /// In en, this message translates to: + /// **'Go to Next Challenge'** + String get go_to_next_challenge; + + /// No description provided for @check_answers. + /// + /// In en, this message translates to: + /// **'Check Answers'** + String get check_answers; + + /// No description provided for @tutorial_written_by_colon. + /// + /// In en, this message translates to: + /// **'Written by: {author}'** + String tutorial_written_by_colon(String author); + + /// No description provided for @error_loading_chapters. + /// + /// In en, this message translates to: + /// **'Error loading chapters: {error} {stackTrace}'** + String error_loading_chapters(String error, String stackTrace); + + /// No description provided for @syntax_error_line. + /// + /// In en, this message translates to: + /// **'There is a syntax error on line {line}. Please check for missing brackets, quotes, or other syntax issues.'** + String syntax_error_line(int line); + + /// No description provided for @syntax_error_general. + /// + /// In en, this message translates to: + /// **'There is a syntax error in your code. Please check for missing brackets, quotes, or other syntax issues.'** + String get syntax_error_general; + + /// No description provided for @unknown_challenge. + /// + /// In en, this message translates to: + /// **'Unknown Challenge, info : {challengeType}'** + String unknown_challenge(String challengeType); + + /// No description provided for @challenge_error. + /// + /// In en, this message translates to: + /// **'Error: {error}\n{stackTrace}'** + String challenge_error(String error, String stackTrace); + /// No description provided for @error. /// /// In en, this message translates to: diff --git a/mobile-app/lib/l10n/app_localizations_en.dart b/mobile-app/lib/l10n/app_localizations_en.dart index 079a1443f..9a9d34c31 100644 --- a/mobile-app/lib/l10n/app_localizations_en.dart +++ b/mobile-app/lib/l10n/app_localizations_en.dart @@ -131,6 +131,14 @@ class AppLocalizationsEn extends AppLocalizations { @override String get passed => 'Passed'; + @override + String get review => 'Review'; + + @override + String steps_complete(String progress) { + return '$progress Steps Complete'; + } + @override String get hint => 'Hint'; @@ -488,6 +496,532 @@ class AppLocalizationsEn extends AppLocalizations { @override String get quincy_email_no_thanks => 'No Thanks'; + @override + String get cancel => 'Cancel'; + + @override + String get leave => 'Leave'; + + @override + String get back => 'Back'; + + @override + String get completed => 'Completed'; + + @override + String get start => 'Start'; + + @override + String get hide_steps => 'Hide Steps'; + + @override + String get show_steps => 'Show Steps'; + + @override + String get hide_challenges => 'Hide Challenges'; + + @override + String get show_challenges => 'Show Challenges'; + + @override + String get hide_tasks => 'Hide Tasks'; + + @override + String get show_tasks => 'Show Tasks'; + + @override + String copied_to_clipboard(String subject) { + return '$subject copied to clipboard!'; + } + + @override + String get email_error => 'Email Error'; + + @override + String get login_email_error_subject => 'Error logging in to mobile app'; + + @override + String login_email_error_message( + String supportEmail, String error, String stackTrace) { + return 'Please email the below error to $supportEmail:\n\n$error\n\n$stackTrace'; + } + + @override + String get quick_action_daily_challenges => 'Daily Challenges'; + + @override + String get code_radio => 'CODE RADIO'; + + @override + String get report_an_issue => 'REPORT AN ISSUE'; + + @override + String login_terms(String termsOfService, String privacyPolicy) { + return 'By continuing, you indicate that you have read and agree to freeCodeCamp.org\'s $termsOfService and $privacyPolicy.'; + } + + @override + String get terms_of_service => 'Terms of Service'; + + @override + String get privacy_policy => 'Privacy Policy'; + + @override + String get no_blocks_available => 'No blocks available right now.'; + + @override + String get coming_soon => 'Coming Soon'; + + @override + String get temporarily_unavailable => + 'Temporarily unavailable, come back soon.'; + + @override + String get not_available_web => 'Not available use the web version'; + + @override + String get stage_core => 'Recommended curriculum (still in beta):'; + + @override + String get stage_english => 'Learn English for Developers:'; + + @override + String get stage_spanish => 'Learn Professional Spanish:'; + + @override + String get stage_chinese => 'Learn Professional Chinese:'; + + @override + String get stage_extra => 'Prepare for the developer interview job search:'; + + @override + String get stage_professional => 'Professional certifications:'; + + @override + String get login_coming_soon => 'Login will soon be available!'; + + @override + String get coming_soon_web => 'Coming soon - use the web version'; + + @override + String get daily_coding_challenges => 'Daily Coding Challenges'; + + @override + String get daily_challenge_empty => 'No challenges available at the moment.'; + + @override + String get daily_challenge_completed => 'Today\'s challenge completed!'; + + @override + String daily_challenge_countdown_label(String timeLeft) { + return 'Countdown timer. Next challenge in $timeLeft'; + } + + @override + String daily_challenge_next_in(String timeLeft) { + return 'Next challenge in: $timeLeft'; + } + + @override + String get daily_challenge_view_past_tooltip => 'View past daily challenges'; + + @override + String get daily_challenge_view_past => 'View past challenges'; + + @override + String get daily_challenge_today => 'Today\'s challenge'; + + @override + String get daily_challenge_prompt => + 'Do you have the skills to complete this challenge?'; + + @override + String get daily_challenge_start_tooltip => 'Start the daily challenge now'; + + @override + String get daily_challenge_go_to_challenge => 'Go to challenge'; + + @override + String get daily_challenge_start => 'Start the challenge'; + + @override + String get daily_challenge_completed_semantics => + 'Daily challenge completed. View past challenges.'; + + @override + String get daily_challenge_card_semantics => 'Daily challenge card'; + + @override + String daily_challenge_month_description(String monthYear) { + return 'Explore the daily coding challenges for $monthYear. Stay motivated and keep your learning streak alive!'; + } + + @override + String daily_challenge_item_title( + int challengeNumber, String challengeTitle) { + return 'Challenge $challengeNumber: $challengeTitle'; + } + + @override + String daily_challenge_item_completed_label( + int challengeNumber, String challengeTitle) { + return 'Challenge $challengeNumber: $challengeTitle, completed'; + } + + @override + String daily_challenge_item_not_completed_label( + int challengeNumber, String challengeTitle) { + return 'Challenge $challengeNumber: $challengeTitle, not completed'; + } + + @override + String get new_daily_challenge_available => + 'New Daily Challenge Available! 🧩'; + + @override + String get daily_challenge_notification_body => + 'A fresh coding challenge is waiting for you. Ready to solve it?'; + + @override + String get daily_challenge_notifications => 'Daily Challenge Notifications'; + + @override + String get daily_challenge_notifications_description => + 'Notifications for new daily coding challenges'; + + @override + String get download_complete => 'Download complete'; + + @override + String get download_episode => 'Download episode'; + + @override + String get notification_channel_description => + 'Notifications for completed podcast episode downloads'; + + @override + String get podcast_pause_episode => 'Pause episode'; + + @override + String get podcast_play_episode => 'Play episode'; + + @override + String get user => 'User'; + + @override + String get profile_location => 'Location'; + + @override + String get profile_x => 'X'; + + @override + String get profile_github => 'GitHub'; + + @override + String get profile_linkedin => 'LinkedIn'; + + @override + String get profile_website => 'Website'; + + @override + String get profile_joined => 'Joined'; + + @override + String get profile_points_label => 'Points'; + + @override + String get profile_longest_streak_label => 'Longest Streak'; + + @override + String get profile_current_streak_label => 'Current Streak'; + + @override + String get profile_about => 'About'; + + @override + String get profile_no_certifications => + 'You have not yet earned any certifications.'; + + @override + String get cert_responsive_web_design => + 'Responsive Web Design Certification'; + + @override + String get cert_javascript_algorithms => + 'JavaScript Algorithms and Data Structures Certification'; + + @override + String get cert_front_end_libraries => + 'Front End Development Libraries Certification'; + + @override + String get cert_data_visualization => 'Data Visualization Certification'; + + @override + String get cert_back_end_development_apis => + 'Back End Development and APIs Certification'; + + @override + String get cert_quality_assurance => 'Quality Assurance Certification'; + + @override + String get cert_information_security => 'Information Security Certification'; + + @override + String get cert_scientific_computing_python => + 'Scientific Computing with Python Certification'; + + @override + String get cert_data_analysis_python => + 'Data Analysis with Python Certification'; + + @override + String get cert_machine_learning_python => + 'Machine Learning with Python Certification'; + + @override + String get cert_relational_database => 'Relational Database Certification'; + + @override + String get cert_college_algebra_python => 'College Algebra with Python'; + + @override + String get cert_foundational_csharp => 'Foundational C# with Microsoft'; + + @override + String get cert_front_end => 'Front End Certification'; + + @override + String get cert_back_end => 'Back End Certification'; + + @override + String get cert_information_security_quality_assurance => + 'Information Security and Quality Assurance Certification'; + + @override + String get cert_full_stack => 'Full Stack Certification'; + + @override + String get challenge_card_description => 'Description'; + + @override + String get challenge_card_video => 'Video'; + + @override + String get challenge_card_watch_video => 'Watch the Video'; + + @override + String get challenge_card_transcript => 'Transcript'; + + @override + String get challenge_card_scene => 'Scene'; + + @override + String get challenge_card_listen_audio => 'Listen to the Audio'; + + @override + String get challenge_card_assignments => 'Assignments'; + + @override + String get challenge_card_explanation => 'Explanation'; + + @override + String get challenge_card_fill_blank => 'Fill in the Blank'; + + @override + String get challenge_card_feedback => 'Feedback'; + + @override + String get challenge_card_lesson => 'Lesson'; + + @override + String get tap_to_expand => 'Tap to expand'; + + @override + String get tests => 'Tests'; + + @override + String get quiz_leave_title => 'Are you sure?'; + + @override + String get quiz_leave_message => + 'Do you want to leave this quiz? Your progress will be lost.'; + + @override + String get quiz_question => 'Question'; + + @override + String quiz_question_number(int questionNumber) { + return 'Question $questionNumber'; + } + + @override + String get quiz_correct => 'Correct!'; + + @override + String get quiz_incorrect => 'Incorrect!'; + + @override + String quiz_unanswered_questions(String questions) { + return 'The following questions are unanswered: $questions. You must answer all questions.'; + } + + @override + String quiz_passed(int correctQuestionsCount, int totalQuestions) { + return 'You have $correctQuestionsCount out of $totalQuestions questions correct. You have passed.'; + } + + @override + String quiz_failed(int correctQuestionsCount, int totalQuestions) { + return 'You have $correctQuestionsCount out of $totalQuestions questions correct. You did not pass.'; + } + + @override + String get some_answers_are_incorrect => 'Some answers are incorrect.'; + + @override + String get show_transcript => 'Show transcript'; + + @override + String get audio_load_error => 'Error loading audio'; + + @override + String get project_link_hint => 'ex: https://replit.com/@camperbot/hello'; + + @override + String get project_link_invalid => 'Please enter a valid link.'; + + @override + String get project_link_own_work => 'Remember to submit your own work.'; + + @override + String get project_link_insecure => 'An unsecure (http) URL cannot be used.'; + + @override + String get project_link_public_url => + 'Remember to submit a publicly visible app URL.'; + + @override + String get forum_create_post => 'Create a post'; + + @override + String get forum_help_description => + 'If you\'ve already tried the Read-Search-Ask method, then you can try asking for help on the freeCodeCamp forum.'; + + @override + String get forum_confirm_description => + 'You must confirm the following statements before you can submit your post to the forum.'; + + @override + String get forum_tell_us_heading => 'Tell us what\'s happening:'; + + @override + String get forum_describe_issue => 'Describe your issue in detail here.'; + + @override + String get forum_code_so_far => 'Your code so far'; + + @override + String get forum_mobile_info => 'Your mobile information:'; + + @override + String get forum_challenge => 'Challenge:'; + + @override + String get forum_challenge_link => 'Link to the challenge:'; + + @override + String get forum_warning => 'WARNING'; + + @override + String get forum_code_too_long => + 'The challenge seed code and/or your solution exceeded the maximum length we can port over from the challenge.'; + + @override + String get forum_additional_step => + 'You will need to take an additional step here so the code you wrote presents in an easy to read format.'; + + @override + String get forum_copy_editor_code => + 'Please copy/paste all the editor code showing in the challenge from where you just linked.'; + + @override + String get forum_replace_code => + 'Replace these two sentences with your copied code.\nPlease leave the ``` line above and the ``` line below,\nbecause they allow your code to properly format in the post.'; + + @override + String get forum_tried => 'I have tried the '; + + @override + String get forum_read_search_ask => 'Read-Search-Ask'; + + @override + String get forum_method => ' method'; + + @override + String get forum_searched_for => 'I have searched for '; + + @override + String get forum_similar_questions => + 'similar questions that have already been answered on the forum'; + + @override + String forum_enter_more_chars(int characterCount) { + return 'Please enter at least $characterCount more characters.'; + } + + @override + String get forum_issue_hint => 'Describe your issue in detail here...'; + + @override + String get forum_help_dialog_prefix => 'If you\'ve already tried the '; + + @override + String get forum_help_dialog_suffix => + ' method, then you can ask for help on the freeCodeCamp forum.'; + + @override + String get forum_before_post => 'Before making a new post please '; + + @override + String get forum_check_answered => + 'check if your question has already been answered on the forum'; + + @override + String get go_to_next_challenge => 'Go to Next Challenge'; + + @override + String get check_answers => 'Check Answers'; + + @override + String tutorial_written_by_colon(String author) { + return 'Written by: $author'; + } + + @override + String error_loading_chapters(String error, String stackTrace) { + return 'Error loading chapters: $error $stackTrace'; + } + + @override + String syntax_error_line(int line) { + return 'There is a syntax error on line $line. Please check for missing brackets, quotes, or other syntax issues.'; + } + + @override + String get syntax_error_general => + 'There is a syntax error in your code. Please check for missing brackets, quotes, or other syntax issues.'; + + @override + String unknown_challenge(String challengeType) { + return 'Unknown Challenge, info : $challengeType'; + } + + @override + String challenge_error(String error, String stackTrace) { + return 'Error: $error\n$stackTrace'; + } + @override String get error => 'Something went wrong!'; diff --git a/mobile-app/lib/l10n/app_localizations_es.dart b/mobile-app/lib/l10n/app_localizations_es.dart index bd365e332..423fbdc6f 100644 --- a/mobile-app/lib/l10n/app_localizations_es.dart +++ b/mobile-app/lib/l10n/app_localizations_es.dart @@ -131,6 +131,14 @@ class AppLocalizationsEs extends AppLocalizations { @override String get passed => 'Passed'; + @override + String get review => 'Review'; + + @override + String steps_complete(String progress) { + return '$progress Steps Complete'; + } + @override String get hint => 'Hint'; @@ -488,6 +496,532 @@ class AppLocalizationsEs extends AppLocalizations { @override String get quincy_email_no_thanks => 'No Thanks'; + @override + String get cancel => 'Cancel'; + + @override + String get leave => 'Leave'; + + @override + String get back => 'Back'; + + @override + String get completed => 'Completed'; + + @override + String get start => 'Start'; + + @override + String get hide_steps => 'Hide Steps'; + + @override + String get show_steps => 'Show Steps'; + + @override + String get hide_challenges => 'Hide Challenges'; + + @override + String get show_challenges => 'Show Challenges'; + + @override + String get hide_tasks => 'Hide Tasks'; + + @override + String get show_tasks => 'Show Tasks'; + + @override + String copied_to_clipboard(String subject) { + return '$subject copied to clipboard!'; + } + + @override + String get email_error => 'Email Error'; + + @override + String get login_email_error_subject => 'Error logging in to mobile app'; + + @override + String login_email_error_message( + String supportEmail, String error, String stackTrace) { + return 'Please email the below error to $supportEmail:\n\n$error\n\n$stackTrace'; + } + + @override + String get quick_action_daily_challenges => 'Daily Challenges'; + + @override + String get code_radio => 'CODE RADIO'; + + @override + String get report_an_issue => 'REPORT AN ISSUE'; + + @override + String login_terms(String termsOfService, String privacyPolicy) { + return 'By continuing, you indicate that you have read and agree to freeCodeCamp.org\'s $termsOfService and $privacyPolicy.'; + } + + @override + String get terms_of_service => 'Terms of Service'; + + @override + String get privacy_policy => 'Privacy Policy'; + + @override + String get no_blocks_available => 'No blocks available right now.'; + + @override + String get coming_soon => 'Coming Soon'; + + @override + String get temporarily_unavailable => + 'Temporarily unavailable, come back soon.'; + + @override + String get not_available_web => 'Not available use the web version'; + + @override + String get stage_core => 'Recommended curriculum (still in beta):'; + + @override + String get stage_english => 'Learn English for Developers:'; + + @override + String get stage_spanish => 'Learn Professional Spanish:'; + + @override + String get stage_chinese => 'Learn Professional Chinese:'; + + @override + String get stage_extra => 'Prepare for the developer interview job search:'; + + @override + String get stage_professional => 'Professional certifications:'; + + @override + String get login_coming_soon => 'Login will soon be available!'; + + @override + String get coming_soon_web => 'Coming soon - use the web version'; + + @override + String get daily_coding_challenges => 'Daily Coding Challenges'; + + @override + String get daily_challenge_empty => 'No challenges available at the moment.'; + + @override + String get daily_challenge_completed => 'Today\'s challenge completed!'; + + @override + String daily_challenge_countdown_label(String timeLeft) { + return 'Countdown timer. Next challenge in $timeLeft'; + } + + @override + String daily_challenge_next_in(String timeLeft) { + return 'Next challenge in: $timeLeft'; + } + + @override + String get daily_challenge_view_past_tooltip => 'View past daily challenges'; + + @override + String get daily_challenge_view_past => 'View past challenges'; + + @override + String get daily_challenge_today => 'Today\'s challenge'; + + @override + String get daily_challenge_prompt => + 'Do you have the skills to complete this challenge?'; + + @override + String get daily_challenge_start_tooltip => 'Start the daily challenge now'; + + @override + String get daily_challenge_go_to_challenge => 'Go to challenge'; + + @override + String get daily_challenge_start => 'Start the challenge'; + + @override + String get daily_challenge_completed_semantics => + 'Daily challenge completed. View past challenges.'; + + @override + String get daily_challenge_card_semantics => 'Daily challenge card'; + + @override + String daily_challenge_month_description(String monthYear) { + return 'Explore the daily coding challenges for $monthYear. Stay motivated and keep your learning streak alive!'; + } + + @override + String daily_challenge_item_title( + int challengeNumber, String challengeTitle) { + return 'Challenge $challengeNumber: $challengeTitle'; + } + + @override + String daily_challenge_item_completed_label( + int challengeNumber, String challengeTitle) { + return 'Challenge $challengeNumber: $challengeTitle, completed'; + } + + @override + String daily_challenge_item_not_completed_label( + int challengeNumber, String challengeTitle) { + return 'Challenge $challengeNumber: $challengeTitle, not completed'; + } + + @override + String get new_daily_challenge_available => + 'New Daily Challenge Available! 🧩'; + + @override + String get daily_challenge_notification_body => + 'A fresh coding challenge is waiting for you. Ready to solve it?'; + + @override + String get daily_challenge_notifications => 'Daily Challenge Notifications'; + + @override + String get daily_challenge_notifications_description => + 'Notifications for new daily coding challenges'; + + @override + String get download_complete => 'Download complete'; + + @override + String get download_episode => 'Download episode'; + + @override + String get notification_channel_description => + 'Notifications for completed podcast episode downloads'; + + @override + String get podcast_pause_episode => 'Pause episode'; + + @override + String get podcast_play_episode => 'Play episode'; + + @override + String get user => 'User'; + + @override + String get profile_location => 'Location'; + + @override + String get profile_x => 'X'; + + @override + String get profile_github => 'GitHub'; + + @override + String get profile_linkedin => 'LinkedIn'; + + @override + String get profile_website => 'Website'; + + @override + String get profile_joined => 'Joined'; + + @override + String get profile_points_label => 'Points'; + + @override + String get profile_longest_streak_label => 'Longest Streak'; + + @override + String get profile_current_streak_label => 'Current Streak'; + + @override + String get profile_about => 'About'; + + @override + String get profile_no_certifications => + 'You have not yet earned any certifications.'; + + @override + String get cert_responsive_web_design => + 'Responsive Web Design Certification'; + + @override + String get cert_javascript_algorithms => + 'JavaScript Algorithms and Data Structures Certification'; + + @override + String get cert_front_end_libraries => + 'Front End Development Libraries Certification'; + + @override + String get cert_data_visualization => 'Data Visualization Certification'; + + @override + String get cert_back_end_development_apis => + 'Back End Development and APIs Certification'; + + @override + String get cert_quality_assurance => 'Quality Assurance Certification'; + + @override + String get cert_information_security => 'Information Security Certification'; + + @override + String get cert_scientific_computing_python => + 'Scientific Computing with Python Certification'; + + @override + String get cert_data_analysis_python => + 'Data Analysis with Python Certification'; + + @override + String get cert_machine_learning_python => + 'Machine Learning with Python Certification'; + + @override + String get cert_relational_database => 'Relational Database Certification'; + + @override + String get cert_college_algebra_python => 'College Algebra with Python'; + + @override + String get cert_foundational_csharp => 'Foundational C# with Microsoft'; + + @override + String get cert_front_end => 'Front End Certification'; + + @override + String get cert_back_end => 'Back End Certification'; + + @override + String get cert_information_security_quality_assurance => + 'Information Security and Quality Assurance Certification'; + + @override + String get cert_full_stack => 'Full Stack Certification'; + + @override + String get challenge_card_description => 'Description'; + + @override + String get challenge_card_video => 'Video'; + + @override + String get challenge_card_watch_video => 'Watch the Video'; + + @override + String get challenge_card_transcript => 'Transcript'; + + @override + String get challenge_card_scene => 'Scene'; + + @override + String get challenge_card_listen_audio => 'Listen to the Audio'; + + @override + String get challenge_card_assignments => 'Assignments'; + + @override + String get challenge_card_explanation => 'Explanation'; + + @override + String get challenge_card_fill_blank => 'Fill in the Blank'; + + @override + String get challenge_card_feedback => 'Feedback'; + + @override + String get challenge_card_lesson => 'Lesson'; + + @override + String get tap_to_expand => 'Tap to expand'; + + @override + String get tests => 'Tests'; + + @override + String get quiz_leave_title => 'Are you sure?'; + + @override + String get quiz_leave_message => + 'Do you want to leave this quiz? Your progress will be lost.'; + + @override + String get quiz_question => 'Question'; + + @override + String quiz_question_number(int questionNumber) { + return 'Question $questionNumber'; + } + + @override + String get quiz_correct => 'Correct!'; + + @override + String get quiz_incorrect => 'Incorrect!'; + + @override + String quiz_unanswered_questions(String questions) { + return 'The following questions are unanswered: $questions. You must answer all questions.'; + } + + @override + String quiz_passed(int correctQuestionsCount, int totalQuestions) { + return 'You have $correctQuestionsCount out of $totalQuestions questions correct. You have passed.'; + } + + @override + String quiz_failed(int correctQuestionsCount, int totalQuestions) { + return 'You have $correctQuestionsCount out of $totalQuestions questions correct. You did not pass.'; + } + + @override + String get some_answers_are_incorrect => 'Some answers are incorrect.'; + + @override + String get show_transcript => 'Show transcript'; + + @override + String get audio_load_error => 'Error loading audio'; + + @override + String get project_link_hint => 'ex: https://replit.com/@camperbot/hello'; + + @override + String get project_link_invalid => 'Please enter a valid link.'; + + @override + String get project_link_own_work => 'Remember to submit your own work.'; + + @override + String get project_link_insecure => 'An unsecure (http) URL cannot be used.'; + + @override + String get project_link_public_url => + 'Remember to submit a publicly visible app URL.'; + + @override + String get forum_create_post => 'Create a post'; + + @override + String get forum_help_description => + 'If you\'ve already tried the Read-Search-Ask method, then you can try asking for help on the freeCodeCamp forum.'; + + @override + String get forum_confirm_description => + 'You must confirm the following statements before you can submit your post to the forum.'; + + @override + String get forum_tell_us_heading => 'Tell us what\'s happening:'; + + @override + String get forum_describe_issue => 'Describe your issue in detail here.'; + + @override + String get forum_code_so_far => 'Your code so far'; + + @override + String get forum_mobile_info => 'Your mobile information:'; + + @override + String get forum_challenge => 'Challenge:'; + + @override + String get forum_challenge_link => 'Link to the challenge:'; + + @override + String get forum_warning => 'WARNING'; + + @override + String get forum_code_too_long => + 'The challenge seed code and/or your solution exceeded the maximum length we can port over from the challenge.'; + + @override + String get forum_additional_step => + 'You will need to take an additional step here so the code you wrote presents in an easy to read format.'; + + @override + String get forum_copy_editor_code => + 'Please copy/paste all the editor code showing in the challenge from where you just linked.'; + + @override + String get forum_replace_code => + 'Replace these two sentences with your copied code.\nPlease leave the ``` line above and the ``` line below,\nbecause they allow your code to properly format in the post.'; + + @override + String get forum_tried => 'I have tried the '; + + @override + String get forum_read_search_ask => 'Read-Search-Ask'; + + @override + String get forum_method => ' method'; + + @override + String get forum_searched_for => 'I have searched for '; + + @override + String get forum_similar_questions => + 'similar questions that have already been answered on the forum'; + + @override + String forum_enter_more_chars(int characterCount) { + return 'Please enter at least $characterCount more characters.'; + } + + @override + String get forum_issue_hint => 'Describe your issue in detail here...'; + + @override + String get forum_help_dialog_prefix => 'If you\'ve already tried the '; + + @override + String get forum_help_dialog_suffix => + ' method, then you can ask for help on the freeCodeCamp forum.'; + + @override + String get forum_before_post => 'Before making a new post please '; + + @override + String get forum_check_answered => + 'check if your question has already been answered on the forum'; + + @override + String get go_to_next_challenge => 'Go to Next Challenge'; + + @override + String get check_answers => 'Check Answers'; + + @override + String tutorial_written_by_colon(String author) { + return 'Written by: $author'; + } + + @override + String error_loading_chapters(String error, String stackTrace) { + return 'Error loading chapters: $error $stackTrace'; + } + + @override + String syntax_error_line(int line) { + return 'There is a syntax error on line $line. Please check for missing brackets, quotes, or other syntax issues.'; + } + + @override + String get syntax_error_general => + 'There is a syntax error in your code. Please check for missing brackets, quotes, or other syntax issues.'; + + @override + String unknown_challenge(String challengeType) { + return 'Unknown Challenge, info : $challengeType'; + } + + @override + String challenge_error(String error, String stackTrace) { + return 'Error: $error\n$stackTrace'; + } + @override String get error => 'Something went wrong!'; diff --git a/mobile-app/lib/l10n/app_localizations_pt.dart b/mobile-app/lib/l10n/app_localizations_pt.dart index c92e65422..9f31e6002 100644 --- a/mobile-app/lib/l10n/app_localizations_pt.dart +++ b/mobile-app/lib/l10n/app_localizations_pt.dart @@ -131,6 +131,14 @@ class AppLocalizationsPt extends AppLocalizations { @override String get passed => 'Passed'; + @override + String get review => 'Review'; + + @override + String steps_complete(String progress) { + return '$progress Steps Complete'; + } + @override String get hint => 'Hint'; @@ -488,6 +496,532 @@ class AppLocalizationsPt extends AppLocalizations { @override String get quincy_email_no_thanks => 'No Thanks'; + @override + String get cancel => 'Cancel'; + + @override + String get leave => 'Leave'; + + @override + String get back => 'Back'; + + @override + String get completed => 'Completed'; + + @override + String get start => 'Start'; + + @override + String get hide_steps => 'Hide Steps'; + + @override + String get show_steps => 'Show Steps'; + + @override + String get hide_challenges => 'Hide Challenges'; + + @override + String get show_challenges => 'Show Challenges'; + + @override + String get hide_tasks => 'Hide Tasks'; + + @override + String get show_tasks => 'Show Tasks'; + + @override + String copied_to_clipboard(String subject) { + return '$subject copied to clipboard!'; + } + + @override + String get email_error => 'Email Error'; + + @override + String get login_email_error_subject => 'Error logging in to mobile app'; + + @override + String login_email_error_message( + String supportEmail, String error, String stackTrace) { + return 'Please email the below error to $supportEmail:\n\n$error\n\n$stackTrace'; + } + + @override + String get quick_action_daily_challenges => 'Daily Challenges'; + + @override + String get code_radio => 'CODE RADIO'; + + @override + String get report_an_issue => 'REPORT AN ISSUE'; + + @override + String login_terms(String termsOfService, String privacyPolicy) { + return 'By continuing, you indicate that you have read and agree to freeCodeCamp.org\'s $termsOfService and $privacyPolicy.'; + } + + @override + String get terms_of_service => 'Terms of Service'; + + @override + String get privacy_policy => 'Privacy Policy'; + + @override + String get no_blocks_available => 'No blocks available right now.'; + + @override + String get coming_soon => 'Coming Soon'; + + @override + String get temporarily_unavailable => + 'Temporarily unavailable, come back soon.'; + + @override + String get not_available_web => 'Not available use the web version'; + + @override + String get stage_core => 'Recommended curriculum (still in beta):'; + + @override + String get stage_english => 'Learn English for Developers:'; + + @override + String get stage_spanish => 'Learn Professional Spanish:'; + + @override + String get stage_chinese => 'Learn Professional Chinese:'; + + @override + String get stage_extra => 'Prepare for the developer interview job search:'; + + @override + String get stage_professional => 'Professional certifications:'; + + @override + String get login_coming_soon => 'Login will soon be available!'; + + @override + String get coming_soon_web => 'Coming soon - use the web version'; + + @override + String get daily_coding_challenges => 'Daily Coding Challenges'; + + @override + String get daily_challenge_empty => 'No challenges available at the moment.'; + + @override + String get daily_challenge_completed => 'Today\'s challenge completed!'; + + @override + String daily_challenge_countdown_label(String timeLeft) { + return 'Countdown timer. Next challenge in $timeLeft'; + } + + @override + String daily_challenge_next_in(String timeLeft) { + return 'Next challenge in: $timeLeft'; + } + + @override + String get daily_challenge_view_past_tooltip => 'View past daily challenges'; + + @override + String get daily_challenge_view_past => 'View past challenges'; + + @override + String get daily_challenge_today => 'Today\'s challenge'; + + @override + String get daily_challenge_prompt => + 'Do you have the skills to complete this challenge?'; + + @override + String get daily_challenge_start_tooltip => 'Start the daily challenge now'; + + @override + String get daily_challenge_go_to_challenge => 'Go to challenge'; + + @override + String get daily_challenge_start => 'Start the challenge'; + + @override + String get daily_challenge_completed_semantics => + 'Daily challenge completed. View past challenges.'; + + @override + String get daily_challenge_card_semantics => 'Daily challenge card'; + + @override + String daily_challenge_month_description(String monthYear) { + return 'Explore the daily coding challenges for $monthYear. Stay motivated and keep your learning streak alive!'; + } + + @override + String daily_challenge_item_title( + int challengeNumber, String challengeTitle) { + return 'Challenge $challengeNumber: $challengeTitle'; + } + + @override + String daily_challenge_item_completed_label( + int challengeNumber, String challengeTitle) { + return 'Challenge $challengeNumber: $challengeTitle, completed'; + } + + @override + String daily_challenge_item_not_completed_label( + int challengeNumber, String challengeTitle) { + return 'Challenge $challengeNumber: $challengeTitle, not completed'; + } + + @override + String get new_daily_challenge_available => + 'New Daily Challenge Available! 🧩'; + + @override + String get daily_challenge_notification_body => + 'A fresh coding challenge is waiting for you. Ready to solve it?'; + + @override + String get daily_challenge_notifications => 'Daily Challenge Notifications'; + + @override + String get daily_challenge_notifications_description => + 'Notifications for new daily coding challenges'; + + @override + String get download_complete => 'Download complete'; + + @override + String get download_episode => 'Download episode'; + + @override + String get notification_channel_description => + 'Notifications for completed podcast episode downloads'; + + @override + String get podcast_pause_episode => 'Pause episode'; + + @override + String get podcast_play_episode => 'Play episode'; + + @override + String get user => 'User'; + + @override + String get profile_location => 'Location'; + + @override + String get profile_x => 'X'; + + @override + String get profile_github => 'GitHub'; + + @override + String get profile_linkedin => 'LinkedIn'; + + @override + String get profile_website => 'Website'; + + @override + String get profile_joined => 'Joined'; + + @override + String get profile_points_label => 'Points'; + + @override + String get profile_longest_streak_label => 'Longest Streak'; + + @override + String get profile_current_streak_label => 'Current Streak'; + + @override + String get profile_about => 'About'; + + @override + String get profile_no_certifications => + 'You have not yet earned any certifications.'; + + @override + String get cert_responsive_web_design => + 'Responsive Web Design Certification'; + + @override + String get cert_javascript_algorithms => + 'JavaScript Algorithms and Data Structures Certification'; + + @override + String get cert_front_end_libraries => + 'Front End Development Libraries Certification'; + + @override + String get cert_data_visualization => 'Data Visualization Certification'; + + @override + String get cert_back_end_development_apis => + 'Back End Development and APIs Certification'; + + @override + String get cert_quality_assurance => 'Quality Assurance Certification'; + + @override + String get cert_information_security => 'Information Security Certification'; + + @override + String get cert_scientific_computing_python => + 'Scientific Computing with Python Certification'; + + @override + String get cert_data_analysis_python => + 'Data Analysis with Python Certification'; + + @override + String get cert_machine_learning_python => + 'Machine Learning with Python Certification'; + + @override + String get cert_relational_database => 'Relational Database Certification'; + + @override + String get cert_college_algebra_python => 'College Algebra with Python'; + + @override + String get cert_foundational_csharp => 'Foundational C# with Microsoft'; + + @override + String get cert_front_end => 'Front End Certification'; + + @override + String get cert_back_end => 'Back End Certification'; + + @override + String get cert_information_security_quality_assurance => + 'Information Security and Quality Assurance Certification'; + + @override + String get cert_full_stack => 'Full Stack Certification'; + + @override + String get challenge_card_description => 'Description'; + + @override + String get challenge_card_video => 'Video'; + + @override + String get challenge_card_watch_video => 'Watch the Video'; + + @override + String get challenge_card_transcript => 'Transcript'; + + @override + String get challenge_card_scene => 'Scene'; + + @override + String get challenge_card_listen_audio => 'Listen to the Audio'; + + @override + String get challenge_card_assignments => 'Assignments'; + + @override + String get challenge_card_explanation => 'Explanation'; + + @override + String get challenge_card_fill_blank => 'Fill in the Blank'; + + @override + String get challenge_card_feedback => 'Feedback'; + + @override + String get challenge_card_lesson => 'Lesson'; + + @override + String get tap_to_expand => 'Tap to expand'; + + @override + String get tests => 'Tests'; + + @override + String get quiz_leave_title => 'Are you sure?'; + + @override + String get quiz_leave_message => + 'Do you want to leave this quiz? Your progress will be lost.'; + + @override + String get quiz_question => 'Question'; + + @override + String quiz_question_number(int questionNumber) { + return 'Question $questionNumber'; + } + + @override + String get quiz_correct => 'Correct!'; + + @override + String get quiz_incorrect => 'Incorrect!'; + + @override + String quiz_unanswered_questions(String questions) { + return 'The following questions are unanswered: $questions. You must answer all questions.'; + } + + @override + String quiz_passed(int correctQuestionsCount, int totalQuestions) { + return 'You have $correctQuestionsCount out of $totalQuestions questions correct. You have passed.'; + } + + @override + String quiz_failed(int correctQuestionsCount, int totalQuestions) { + return 'You have $correctQuestionsCount out of $totalQuestions questions correct. You did not pass.'; + } + + @override + String get some_answers_are_incorrect => 'Some answers are incorrect.'; + + @override + String get show_transcript => 'Show transcript'; + + @override + String get audio_load_error => 'Error loading audio'; + + @override + String get project_link_hint => 'ex: https://replit.com/@camperbot/hello'; + + @override + String get project_link_invalid => 'Please enter a valid link.'; + + @override + String get project_link_own_work => 'Remember to submit your own work.'; + + @override + String get project_link_insecure => 'An unsecure (http) URL cannot be used.'; + + @override + String get project_link_public_url => + 'Remember to submit a publicly visible app URL.'; + + @override + String get forum_create_post => 'Create a post'; + + @override + String get forum_help_description => + 'If you\'ve already tried the Read-Search-Ask method, then you can try asking for help on the freeCodeCamp forum.'; + + @override + String get forum_confirm_description => + 'You must confirm the following statements before you can submit your post to the forum.'; + + @override + String get forum_tell_us_heading => 'Tell us what\'s happening:'; + + @override + String get forum_describe_issue => 'Describe your issue in detail here.'; + + @override + String get forum_code_so_far => 'Your code so far'; + + @override + String get forum_mobile_info => 'Your mobile information:'; + + @override + String get forum_challenge => 'Challenge:'; + + @override + String get forum_challenge_link => 'Link to the challenge:'; + + @override + String get forum_warning => 'WARNING'; + + @override + String get forum_code_too_long => + 'The challenge seed code and/or your solution exceeded the maximum length we can port over from the challenge.'; + + @override + String get forum_additional_step => + 'You will need to take an additional step here so the code you wrote presents in an easy to read format.'; + + @override + String get forum_copy_editor_code => + 'Please copy/paste all the editor code showing in the challenge from where you just linked.'; + + @override + String get forum_replace_code => + 'Replace these two sentences with your copied code.\nPlease leave the ``` line above and the ``` line below,\nbecause they allow your code to properly format in the post.'; + + @override + String get forum_tried => 'I have tried the '; + + @override + String get forum_read_search_ask => 'Read-Search-Ask'; + + @override + String get forum_method => ' method'; + + @override + String get forum_searched_for => 'I have searched for '; + + @override + String get forum_similar_questions => + 'similar questions that have already been answered on the forum'; + + @override + String forum_enter_more_chars(int characterCount) { + return 'Please enter at least $characterCount more characters.'; + } + + @override + String get forum_issue_hint => 'Describe your issue in detail here...'; + + @override + String get forum_help_dialog_prefix => 'If you\'ve already tried the '; + + @override + String get forum_help_dialog_suffix => + ' method, then you can ask for help on the freeCodeCamp forum.'; + + @override + String get forum_before_post => 'Before making a new post please '; + + @override + String get forum_check_answered => + 'check if your question has already been answered on the forum'; + + @override + String get go_to_next_challenge => 'Go to Next Challenge'; + + @override + String get check_answers => 'Check Answers'; + + @override + String tutorial_written_by_colon(String author) { + return 'Written by: $author'; + } + + @override + String error_loading_chapters(String error, String stackTrace) { + return 'Error loading chapters: $error $stackTrace'; + } + + @override + String syntax_error_line(int line) { + return 'There is a syntax error on line $line. Please check for missing brackets, quotes, or other syntax issues.'; + } + + @override + String get syntax_error_general => + 'There is a syntax error in your code. Please check for missing brackets, quotes, or other syntax issues.'; + + @override + String unknown_challenge(String challengeType) { + return 'Unknown Challenge, info : $challengeType'; + } + + @override + String challenge_error(String error, String stackTrace) { + return 'Error: $error\n$stackTrace'; + } + @override String get error => 'Something went wrong!'; diff --git a/mobile-app/lib/l10n/app_pt.arb b/mobile-app/lib/l10n/app_pt.arb index 23e4124a9..dfa3c78f8 100644 --- a/mobile-app/lib/l10n/app_pt.arb +++ b/mobile-app/lib/l10n/app_pt.arb @@ -153,6 +153,15 @@ "@passed": { "description": "learn > block > challenge > *complete challenge* > pass panel" }, + "review": "Review", + "steps_complete": "{progress} Steps Complete", + "@steps_complete": { + "placeholders": { + "progress": { + "type": "String" + } + } + }, "hint": "Hint", "@hint": { "description": "learn > block > challenge > *fail challenge* > hint panel" @@ -409,6 +418,10 @@ "@settings_privacy_policy_description": { "description": "settings view" }, + "coderadio_unable_to_load": "Unable to load coderadio \n please try again.", + "@coderadio_unable_to_load": { + "description": "coderadio view" + }, "coderadio_next_song": "Next Song", "@coderadio_next_song": { "description": "coderadio view (only on Android)" @@ -546,6 +559,321 @@ "quincy_email_part_fifteen": "By the way, each Friday I send an email with 5 links about programming and computer science. I send these to about 4 million people. Would you like me to send this to you, too?", "quincy_email_confirm": "Yes Please", "quincy_email_no_thanks": "No Thanks", + "cancel": "Cancel", + "leave": "Leave", + "back": "Back", + "completed": "Completed", + "start": "Start", + "hide_steps": "Hide Steps", + "show_steps": "Show Steps", + "hide_challenges": "Hide Challenges", + "show_challenges": "Show Challenges", + "hide_tasks": "Hide Tasks", + "show_tasks": "Show Tasks", + "copied_to_clipboard": "{subject} copied to clipboard!", + "@copied_to_clipboard": { + "placeholders": { + "subject": { + "type": "String" + } + } + }, + "email_error": "Email Error", + "login_email_error_subject": "Error logging in to mobile app", + "login_email_error_message": "Please email the below error to {supportEmail}:\n\n{error}\n\n{stackTrace}", + "@login_email_error_message": { + "placeholders": { + "supportEmail": { + "type": "String" + }, + "error": { + "type": "String" + }, + "stackTrace": { + "type": "String" + } + } + }, + "quick_action_daily_challenges": "Daily Challenges", + "code_radio": "CODE RADIO", + "report_an_issue": "REPORT AN ISSUE", + "login_terms": "By continuing, you indicate that you have read and agree to freeCodeCamp.org's {termsOfService} and {privacyPolicy}.", + "@login_terms": { + "placeholders": { + "termsOfService": { + "type": "String" + }, + "privacyPolicy": { + "type": "String" + } + } + }, + "terms_of_service": "Terms of Service", + "privacy_policy": "Privacy Policy", + "no_blocks_available": "No blocks available right now.", + "coming_soon": "Coming Soon", + "temporarily_unavailable": "Temporarily unavailable, come back soon.", + "not_available_web": "Not available use the web version", + "stage_core": "Recommended curriculum (still in beta):", + "stage_english": "Learn English for Developers:", + "stage_spanish": "Learn Professional Spanish:", + "stage_chinese": "Learn Professional Chinese:", + "stage_extra": "Prepare for the developer interview job search:", + "stage_professional": "Professional certifications:", + "login_coming_soon": "Login will soon be available!", + "coming_soon_web": "Coming soon - use the web version", + "daily_coding_challenges": "Daily Coding Challenges", + "daily_challenge_empty": "No challenges available at the moment.", + "daily_challenge_completed": "Today's challenge completed!", + "daily_challenge_countdown_label": "Countdown timer. Next challenge in {timeLeft}", + "@daily_challenge_countdown_label": { + "placeholders": { + "timeLeft": { + "type": "String" + } + } + }, + "daily_challenge_next_in": "Next challenge in: {timeLeft}", + "@daily_challenge_next_in": { + "placeholders": { + "timeLeft": { + "type": "String" + } + } + }, + "daily_challenge_view_past_tooltip": "View past daily challenges", + "daily_challenge_view_past": "View past challenges", + "daily_challenge_today": "Today's challenge", + "daily_challenge_prompt": "Do you have the skills to complete this challenge?", + "daily_challenge_start_tooltip": "Start the daily challenge now", + "daily_challenge_go_to_challenge": "Go to challenge", + "daily_challenge_start": "Start the challenge", + "daily_challenge_completed_semantics": "Daily challenge completed. View past challenges.", + "daily_challenge_card_semantics": "Daily challenge card", + "daily_challenge_month_description": "Explore the daily coding challenges for {monthYear}. Stay motivated and keep your learning streak alive!", + "@daily_challenge_month_description": { + "placeholders": { + "monthYear": { + "type": "String" + } + } + }, + "daily_challenge_item_title": "Challenge {challengeNumber}: {challengeTitle}", + "@daily_challenge_item_title": { + "placeholders": { + "challengeNumber": { + "type": "int" + }, + "challengeTitle": { + "type": "String" + } + } + }, + "daily_challenge_item_completed_label": "Challenge {challengeNumber}: {challengeTitle}, completed", + "@daily_challenge_item_completed_label": { + "placeholders": { + "challengeNumber": { + "type": "int" + }, + "challengeTitle": { + "type": "String" + } + } + }, + "daily_challenge_item_not_completed_label": "Challenge {challengeNumber}: {challengeTitle}, not completed", + "@daily_challenge_item_not_completed_label": { + "placeholders": { + "challengeNumber": { + "type": "int" + }, + "challengeTitle": { + "type": "String" + } + } + }, + "new_daily_challenge_available": "New Daily Challenge Available! 🧩", + "daily_challenge_notification_body": "A fresh coding challenge is waiting for you. Ready to solve it?", + "daily_challenge_notifications": "Daily Challenge Notifications", + "daily_challenge_notifications_description": "Notifications for new daily coding challenges", + "download_complete": "Download complete", + "download_episode": "Download episode", + "notification_channel_description": "Notifications for completed podcast episode downloads", + "podcast_pause_episode": "Pause episode", + "podcast_play_episode": "Play episode", + "user": "User", + "profile_location": "Location", + "profile_x": "X", + "profile_github": "GitHub", + "profile_linkedin": "LinkedIn", + "profile_website": "Website", + "profile_joined": "Joined", + "profile_points_label": "Points", + "profile_longest_streak_label": "Longest Streak", + "profile_current_streak_label": "Current Streak", + "profile_about": "About", + "profile_no_certifications": "You have not yet earned any certifications.", + "cert_responsive_web_design": "Responsive Web Design Certification", + "cert_javascript_algorithms": "JavaScript Algorithms and Data Structures Certification", + "cert_front_end_libraries": "Front End Development Libraries Certification", + "cert_data_visualization": "Data Visualization Certification", + "cert_back_end_development_apis": "Back End Development and APIs Certification", + "cert_quality_assurance": "Quality Assurance Certification", + "cert_information_security": "Information Security Certification", + "cert_scientific_computing_python": "Scientific Computing with Python Certification", + "cert_data_analysis_python": "Data Analysis with Python Certification", + "cert_machine_learning_python": "Machine Learning with Python Certification", + "cert_relational_database": "Relational Database Certification", + "cert_college_algebra_python": "College Algebra with Python", + "cert_foundational_csharp": "Foundational C# with Microsoft", + "cert_front_end": "Front End Certification", + "cert_back_end": "Back End Certification", + "cert_information_security_quality_assurance": "Information Security and Quality Assurance Certification", + "cert_full_stack": "Full Stack Certification", + "challenge_card_description": "Description", + "challenge_card_video": "Video", + "challenge_card_watch_video": "Watch the Video", + "challenge_card_transcript": "Transcript", + "challenge_card_scene": "Scene", + "challenge_card_listen_audio": "Listen to the Audio", + "challenge_card_assignments": "Assignments", + "challenge_card_explanation": "Explanation", + "challenge_card_fill_blank": "Fill in the Blank", + "challenge_card_feedback": "Feedback", + "challenge_card_lesson": "Lesson", + "tap_to_expand": "Tap to expand", + "tests": "Tests", + "quiz_leave_title": "Are you sure?", + "quiz_leave_message": "Do you want to leave this quiz? Your progress will be lost.", + "quiz_question": "Question", + "quiz_question_number": "Question {questionNumber}", + "@quiz_question_number": { + "placeholders": { + "questionNumber": { + "type": "int" + } + } + }, + "quiz_correct": "Correct!", + "quiz_incorrect": "Incorrect!", + "quiz_unanswered_questions": "The following questions are unanswered: {questions}. You must answer all questions.", + "@quiz_unanswered_questions": { + "placeholders": { + "questions": { + "type": "String" + } + } + }, + "quiz_passed": "You have {correctQuestionsCount} out of {totalQuestions} questions correct. You have passed.", + "@quiz_passed": { + "placeholders": { + "correctQuestionsCount": { + "type": "int" + }, + "totalQuestions": { + "type": "int" + } + } + }, + "quiz_failed": "You have {correctQuestionsCount} out of {totalQuestions} questions correct. You did not pass.", + "@quiz_failed": { + "placeholders": { + "correctQuestionsCount": { + "type": "int" + }, + "totalQuestions": { + "type": "int" + } + } + }, + "some_answers_are_incorrect": "Some answers are incorrect.", + "show_transcript": "Show transcript", + "audio_load_error": "Error loading audio", + "project_link_hint": "ex: https://replit.com/@camperbot/hello", + "project_link_invalid": "Please enter a valid link.", + "project_link_own_work": "Remember to submit your own work.", + "project_link_insecure": "An unsecure (http) URL cannot be used.", + "project_link_public_url": "Remember to submit a publicly visible app URL.", + "forum_create_post": "Create a post", + "forum_help_description": "If you've already tried the Read-Search-Ask method, then you can try asking for help on the freeCodeCamp forum.", + "forum_confirm_description": "You must confirm the following statements before you can submit your post to the forum.", + "forum_tell_us_heading": "Tell us what's happening:", + "forum_describe_issue": "Describe your issue in detail here.", + "forum_code_so_far": "Your code so far", + "forum_mobile_info": "Your mobile information:", + "forum_challenge": "Challenge:", + "forum_challenge_link": "Link to the challenge:", + "forum_warning": "WARNING", + "forum_code_too_long": "The challenge seed code and/or your solution exceeded the maximum length we can port over from the challenge.", + "forum_additional_step": "You will need to take an additional step here so the code you wrote presents in an easy to read format.", + "forum_copy_editor_code": "Please copy/paste all the editor code showing in the challenge from where you just linked.", + "forum_replace_code": "Replace these two sentences with your copied code.\nPlease leave the ``` line above and the ``` line below,\nbecause they allow your code to properly format in the post.", + "forum_tried": "I have tried the ", + "forum_read_search_ask": "Read-Search-Ask", + "forum_method": " method", + "forum_searched_for": "I have searched for ", + "forum_similar_questions": "similar questions that have already been answered on the forum", + "forum_enter_more_chars": "Please enter at least {characterCount} more characters.", + "@forum_enter_more_chars": { + "placeholders": { + "characterCount": { + "type": "int" + } + } + }, + "forum_issue_hint": "Describe your issue in detail here...", + "forum_help_dialog_prefix": "If you've already tried the ", + "forum_help_dialog_suffix": " method, then you can ask for help on the freeCodeCamp forum.", + "forum_before_post": "Before making a new post please ", + "forum_check_answered": "check if your question has already been answered on the forum", + "go_to_next_challenge": "Go to Next Challenge", + "check_answers": "Check Answers", + "tutorial_written_by_colon": "Written by: {author}", + "@tutorial_written_by_colon": { + "placeholders": { + "author": { + "type": "String" + } + } + }, + "error_loading_chapters": "Error loading chapters: {error} {stackTrace}", + "@error_loading_chapters": { + "placeholders": { + "error": { + "type": "String" + }, + "stackTrace": { + "type": "String" + } + } + }, + "syntax_error_line": "There is a syntax error on line {line}. Please check for missing brackets, quotes, or other syntax issues.", + "@syntax_error_line": { + "placeholders": { + "line": { + "type": "int" + } + } + }, + "syntax_error_general": "There is a syntax error in your code. Please check for missing brackets, quotes, or other syntax issues.", + "unknown_challenge": "Unknown Challenge, info : {challengeType}", + "@unknown_challenge": { + "placeholders": { + "challengeType": { + "type": "String" + } + } + }, + "challenge_error": "Error: {error}\n{stackTrace}", + "@challenge_error": { + "placeholders": { + "error": { + "type": "String" + }, + "stackTrace": { + "type": "String" + } + } + }, "error": "Something went wrong!", "error_two": "Error", "error_three": "Oops! Something went wrong. Please try again in a moment.", diff --git a/mobile-app/lib/main.dart b/mobile-app/lib/main.dart index bc7a7b893..358ec9fed 100644 --- a/mobile-app/lib/main.dart +++ b/mobile-app/lib/main.dart @@ -19,8 +19,10 @@ import 'package:freecodecamp/service/locale_service.dart'; import 'package:freecodecamp/service/navigation/quick_actions_service.dart'; import 'package:freecodecamp/service/news/api_service.dart'; import 'package:freecodecamp/service/podcast/notification_service.dart'; +import 'package:freecodecamp/service/symbol_bar_service.dart'; import 'package:freecodecamp/ui/theme/fcc_theme.dart'; import 'package:freecodecamp/utils/upgrade_controller.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'package:stacked_services/stacked_services.dart'; import 'package:upgrader/upgrader.dart'; @@ -33,6 +35,9 @@ Future main({bool testing = false}) async { await AppAudioService().init(); await AuthenticationService().init(); await NewsApiService().init(); + await locator().init( + await SharedPreferences.getInstance(), + ); var fbApp = await Firebase.initializeApp( options: DefaultFirebaseOptions.currentPlatform, ); diff --git a/mobile-app/lib/models/symbol_set_model.dart b/mobile-app/lib/models/symbol_set_model.dart new file mode 100644 index 000000000..b50f477a8 --- /dev/null +++ b/mobile-app/lib/models/symbol_set_model.dart @@ -0,0 +1,257 @@ +/// Symbol Set Models for the customizable quick-access symbol bar +/// +/// Architecture: +/// - [SymbolSetType]: Enum for predefined sets (Python, JavaScript, HTML/CSS) +/// - [SymbolSet]: Base model for any symbol set (predefined or custom) +/// - [PredefinedSymbolSet]: Sealed implementation of predefined sets +/// - [CustomSymbolSet]: User-defined symbol set +/// - [SymbolBarState]: Complete state including active set and enabled custom symbols +library; + +enum SymbolSetType { + python('Python'), + javascript('JavaScript'), + htmlCss('HTML/CSS'); + + final String displayName; + const SymbolSetType(this.displayName); + + /// Convert string to enum value (case-insensitive) + static SymbolSetType fromValue(String value) { + return SymbolSetType.values.firstWhere( + (type) => type.name.toLowerCase() == value.toLowerCase(), + orElse: () => SymbolSetType.python, // Default fallback + ); + } +} + +/// Abstract base class for symbol sets +/// Enables type safety and future extensibility +abstract class SymbolSet { + String get name; + List get symbols; + bool get isCustom; + + /// Create a copy with modified values + SymbolSet copyWith({List? symbols}); + + /// Convert to JSON for storage + Map toJson(); + + /// Create from JSON + factory SymbolSet.fromJson(Map json) { + if (json['isCustom'] == true) { + return CustomSymbolSet.fromJson(json); + } + return PredefinedSymbolSet.fromJson(json); + } +} + +/// Predefined symbol sets (Python, JavaScript, HTML/CSS) +/// Immutable and generated from constants +class PredefinedSymbolSet implements SymbolSet { + final SymbolSetType type; + + PredefinedSymbolSet(this.type); + + @override + String get name => type.displayName; + + @override + List get symbols { + switch (type) { + case SymbolSetType.python: + return _pythonSymbols; + case SymbolSetType.javascript: + return _javascriptSymbols; + case SymbolSetType.htmlCss: + return _htmlCssSymbols; + } + } + + @override + bool get isCustom => false; + + @override + SymbolSet copyWith({List? symbols}) { + // Predefined sets are immutable, return self + return this; + } + + @override + Map toJson() { + return { + 'type': type.name, + 'isCustom': false, + 'symbols': symbols, + }; + } + + factory PredefinedSymbolSet.fromJson(Map json) { + final type = SymbolSetType.fromValue(json['type'] as String? ?? 'python'); + return PredefinedSymbolSet(type); + } + + // Python symbol set - ideal for Python, Ruby, and other interpreted languages + static const List _pythonSymbols = [ + 'Tab', + '(', + ')', + '[', + ']', + '{', + '}', + ':', + '#', + 'def', + 'class', + '=', + ]; + + // JavaScript symbol set - ideal for JavaScript, TypeScript, and modern web development + static const List _javascriptSymbols = [ + '(', + ')', + '{', + '}', + '[', + ']', + ';', + '=>', + 'const', + 'let', + 'var', + '=', + ]; + + // HTML/CSS symbol set - ideal for web markup and styling + static const List _htmlCssSymbols = [ + '<', + '>', + '/', + 'class=', + 'id=', + 'div', + 'p', + 'span', + ';', + '{', + '}', + ':', + ]; +} + +/// User-defined custom symbol set +/// Mutable and persisted to SharedPreferences +class CustomSymbolSet implements SymbolSet { + @override + final String name; + + @override + final List symbols; + + CustomSymbolSet({ + required this.name, + required this.symbols, + }); + + @override + bool get isCustom => true; + + @override + SymbolSet copyWith({List? symbols}) { + return CustomSymbolSet( + name: name, + symbols: symbols ?? this.symbols, + ); + } + + @override + Map toJson() { + return { + 'name': name, + 'symbols': symbols, + 'isCustom': true, + }; + } + + factory CustomSymbolSet.fromJson(Map json) { + return CustomSymbolSet( + name: json['name'] as String? ?? 'Custom', + symbols: List.from(json['symbols'] as List? ?? []), + ); + } +} + +/// Complete symbol bar state +/// Used for persistence and reactive state management +class SymbolBarState { + /// Currently active symbol set (predefined or custom) + final SymbolSet activeSet; + + /// Whether custom symbols are enabled + final bool customSymbolsEnabled; + + /// All user-defined custom symbol sets + final List customSymbolSets; + + SymbolBarState({ + required this.activeSet, + this.customSymbolsEnabled = false, + this.customSymbolSets = const [], + }); + + /// Get the currently displayed symbols + List get currentSymbols => activeSet.symbols; + + /// Create a copy with modified values + SymbolBarState copyWith({ + SymbolSet? activeSet, + bool? customSymbolsEnabled, + List? customSymbolSets, + }) { + return SymbolBarState( + activeSet: activeSet ?? this.activeSet, + customSymbolsEnabled: customSymbolsEnabled ?? this.customSymbolsEnabled, + customSymbolSets: customSymbolSets ?? this.customSymbolSets, + ); + } + + /// Convert to JSON for storage + Map toJson() { + return { + 'activeSet': activeSet.toJson(), + 'customSymbolsEnabled': customSymbolsEnabled, + 'customSymbolSets': customSymbolSets.map((s) => s.toJson()).toList(), + }; + } + + /// Create from JSON + factory SymbolBarState.fromJson(Map json) { + final activeSetJson = json['activeSet'] as Map?; + final activeSet = activeSetJson != null + ? SymbolSet.fromJson(activeSetJson) + : PredefinedSymbolSet(SymbolSetType.python); + + final customSymbolSetsJson = json['customSymbolSets'] as List?; + final customSymbolSets = (customSymbolSetsJson ?? []) + .cast>() + .map((s) => CustomSymbolSet.fromJson(s)) + .toList(); + + return SymbolBarState( + activeSet: activeSet, + customSymbolsEnabled: json['customSymbolsEnabled'] as bool? ?? false, + customSymbolSets: customSymbolSets, + ); + } + + /// Create default state (Python set active) + factory SymbolBarState.defaultState() { + return SymbolBarState( + activeSet: PredefinedSymbolSet(SymbolSetType.python), + customSymbolsEnabled: false, + customSymbolSets: [], + ); + } +} diff --git a/mobile-app/lib/service/authentication/authentication_service.dart b/mobile-app/lib/service/authentication/authentication_service.dart index 0684f9988..01d2ce430 100644 --- a/mobile-app/lib/service/authentication/authentication_service.dart +++ b/mobile-app/lib/service/authentication/authentication_service.dart @@ -183,8 +183,7 @@ class AuthenticationService { .webAuthentication(scheme: kReleaseMode ? null : 'org.freecodecamp') .login(useHTTPS: true, parameters: {'connection': connectionType}); } - } on WebAuthenticationException catch (e) { - + } on WebAuthenticationException catch (e) { log('message: WebAuthenticationException: ${e.message}'); // NOTE: The most likely case is that the user canceled the login @@ -220,11 +219,14 @@ class AuthenticationService { await writeTokensToStorage(); await fetchUser(); } on DioException catch (err, st) { - String subject = Uri.encodeComponent('Error logging in to mobile app'); + String subject = Uri.encodeComponent(context.t.login_email_error_subject); Navigator.pop(context); if (err.response != null) { - String body = Uri.encodeComponent( - 'Please email the below error to ${AuthenticationService.supportEmail}\n\n${err.response!.data.toString()}\n\n${st.toString()}'); + String body = Uri.encodeComponent(context.t.login_email_error_message( + AuthenticationService.supportEmail, + err.response!.data.toString(), + st.toString(), + )); await showDialog( context: context, barrierDismissible: false, @@ -239,9 +241,11 @@ class AuthenticationService { ), content: SingleChildScrollView( child: SelectionArea( - child: Text( - 'Please email the below error to ${AuthenticationService.supportEmail}:\n\n${err.response!.data.toString()}\n\n${st.toString()}', - ), + child: Text(context.t.login_email_error_message( + AuthenticationService.supportEmail, + err.response!.data.toString(), + st.toString(), + )), ), ), actions: [ @@ -255,7 +259,7 @@ class AuthenticationService { 'mailto:${AuthenticationService.supportEmail}?subject=$subject&body=$body')); Navigator.pop(context); }, - child: const Text('Email Error'), + child: Text(context.t.email_error), ), TextButton( style: TextButton.styleFrom( @@ -271,8 +275,11 @@ class AuthenticationService { ), ); } else { - String body = Uri.encodeComponent( - 'Please email the below error to ${AuthenticationService.supportEmail}\n\n${err.toString()}\n\n${st.toString()}'); + String body = Uri.encodeComponent(context.t.login_email_error_message( + AuthenticationService.supportEmail, + err.toString(), + st.toString(), + )); await showDialog( context: context, barrierDismissible: false, @@ -287,9 +294,11 @@ class AuthenticationService { title: Text(context.t.error_two), content: SingleChildScrollView( child: SelectionArea( - child: Text( - 'Please email the below error to ${AuthenticationService.supportEmail}:\n\n${err.toString()}\n\n${st.toString()}', - ), + child: Text(context.t.login_email_error_message( + AuthenticationService.supportEmail, + err.toString(), + st.toString(), + )), ), ), actions: [ @@ -303,7 +312,7 @@ class AuthenticationService { 'mailto:${AuthenticationService.supportEmail}?subject=$subject&body=$body')); Navigator.pop(context); }, - child: const Text('Email Error'), + child: Text(context.t.email_error), ), TextButton( style: TextButton.styleFrom( diff --git a/mobile-app/lib/service/firebase/remote_config_service.dart b/mobile-app/lib/service/firebase/remote_config_service.dart index 1ee62c076..85acaba73 100644 --- a/mobile-app/lib/service/firebase/remote_config_service.dart +++ b/mobile-app/lib/service/firebase/remote_config_service.dart @@ -35,7 +35,7 @@ class RemoteConfigService { ), ); await remoteConfig.setDefaults({ - 'min_app_version': '7.5.1', + 'min_app_version': '7.5.2', _activationOverridesKey: '{}', }); diff --git a/mobile-app/lib/service/learn/daily_challenge_notification_service.dart b/mobile-app/lib/service/learn/daily_challenge_notification_service.dart index 550b3e8b9..0e599aaea 100644 --- a/mobile-app/lib/service/learn/daily_challenge_notification_service.dart +++ b/mobile-app/lib/service/learn/daily_challenge_notification_service.dart @@ -5,10 +5,12 @@ import 'dart:math'; import 'package:flutter/services.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:freecodecamp/app/app.locator.dart'; +import 'package:freecodecamp/l10n/app_localizations.dart'; import 'package:freecodecamp/models/learn/completed_challenge_model.dart'; import 'package:freecodecamp/models/main/user_model.dart'; import 'package:freecodecamp/service/authentication/authentication_service.dart'; import 'package:freecodecamp/service/learn/daily_challenge_service.dart'; +import 'package:freecodecamp/service/locale_service.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:timezone/data/latest.dart' as tz; import 'package:timezone/timezone.dart' as tz; @@ -17,6 +19,7 @@ class DailyChallengeNotificationService { final FlutterLocalNotificationsPlugin _flutterLocalNotificationsPlugin; final DailyChallengeService _dailyChallengeService; final AuthenticationService _authenticationService; + final LocaleService _localeService; Random random = Random(); static const String _channelId = 'daily_challenge_channel'; @@ -26,12 +29,14 @@ class DailyChallengeNotificationService { FlutterLocalNotificationsPlugin? flutterLocalNotificationsPlugin, DailyChallengeService? dailyChallengeService, AuthenticationService? authenticationService, + LocaleService? localeService, }) : _flutterLocalNotificationsPlugin = flutterLocalNotificationsPlugin ?? FlutterLocalNotificationsPlugin(), _dailyChallengeService = dailyChallengeService ?? locator(), _authenticationService = - authenticationService ?? locator(); + authenticationService ?? locator(), + _localeService = localeService ?? locator(); Future init() async { tz.initializeTimeZones(); @@ -307,6 +312,7 @@ class DailyChallengeNotificationService { DateTime startSchedulingFrom, DateTime now) async { final centralLocation = tz.getLocation('America/Chicago'); final endDate = startSchedulingFrom.add(const Duration(days: 7)); + final t = lookupAppLocalizations(_localeService.locale); int notificationId = 0; for (DateTime date = startSchedulingFrom; @@ -319,19 +325,18 @@ class DailyChallengeNotificationService { try { await _flutterLocalNotificationsPlugin.zonedSchedule( notificationId++, - 'New Daily Challenge Available! 🧩', - 'A fresh coding challenge is waiting for you. Ready to solve it?', + t.new_daily_challenge_available, + t.daily_challenge_notification_body, tz.TZDateTime.from(scheduleTime, tz.local), - const NotificationDetails( + NotificationDetails( android: AndroidNotificationDetails( _channelId, - 'Daily Challenge Notifications', - channelDescription: - 'Notifications for new daily coding challenges', + t.daily_challenge_notifications, + channelDescription: t.daily_challenge_notifications_description, priority: Priority.high, importance: Importance.max, ), - iOS: DarwinNotificationDetails( + iOS: const DarwinNotificationDetails( threadIdentifier: 'daily-challenge-notification', categoryIdentifier: 'DAILY_CHALLENGE', ), diff --git a/mobile-app/lib/service/learn/learn_service.dart b/mobile-app/lib/service/learn/learn_service.dart index eed7d1122..4082fd6fb 100644 --- a/mobile-app/lib/service/learn/learn_service.dart +++ b/mobile-app/lib/service/learn/learn_service.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:freecodecamp/app/app.locator.dart'; import 'package:freecodecamp/app/app.router.dart'; import 'package:freecodecamp/enums/dialog_type.dart'; +import 'package:freecodecamp/extensions/i18n_extension.dart'; import 'package:freecodecamp/models/learn/challenge_model.dart'; import 'package:freecodecamp/models/learn/curriculum_model.dart'; import 'package:freecodecamp/models/learn/daily_challenge_model.dart'; @@ -212,15 +213,16 @@ class LearnService { final userDeviceInfo = await getDeviceInfo(context); final titleText = '$blockTitle - ${currChallenge.title}'; + final t = context.t; final String endingText = - '**Your mobile information:**\n```txt\n$userDeviceInfo\n```\n\n**Challenge:** $titleText\n\n**Link to the challenge:**\nhttps://www.freecodecamp.org/learn/${currChallenge.superBlock}/${currChallenge.block}/${currChallenge.dashedName}'; + '**${t.forum_mobile_info}**\n```txt\n$userDeviceInfo\n```\n\n**${t.forum_challenge}** $titleText\n\n**${t.forum_challenge_link}**\nhttps://www.freecodecamp.org/learn/${currChallenge.superBlock}/${currChallenge.block}/${currChallenge.dashedName}'; final String userCode = await filesToMarkdown(currChallenge, editorText); final String textMessage = - "**Tell us what's happening:**\nDescribe your issue in detail here. \n\n$description \n\n**Your code so far**$userCode\n\n$endingText"; + '**${t.forum_tell_us_heading}**\n${t.forum_describe_issue} \n\n$description \n\n**${t.forum_code_so_far}**$userCode\n\n$endingText'; final String altTextMessage = - "**Tell us what's happening:**\n\n\n\n**Your code so far**\n\nWARNING\n\nThe challenge seed code and/or your solution exceeded the maximum length we can port over from the challenge.\n\nYou will need to take an additional step here so the code you wrote presents in an easy to read format.\n\nPlease copy/paste all the editor code showing in the challenge from where you just linked.\n\n```\nReplace these two sentences with your copied code.\nPlease leave the ``` line above and the ``` line below,\nbecause they allow your code to properly format in the post.\n\n```\n$endingText"; + '**${t.forum_tell_us_heading}**\n\n\n\n**${t.forum_code_so_far}**\n\n${t.forum_warning}\n\n${t.forum_code_too_long}\n\n${t.forum_additional_step}\n\n${t.forum_copy_editor_code}\n\n```\n${t.forum_replace_code}\n\n```\n$endingText'; String studentCode = Uri.encodeComponent(textMessage); String altStudentCode = Uri.encodeComponent(altTextMessage); @@ -241,19 +243,17 @@ class LearnService { DialogResponse? res = await _dialogService.showCustomDialog( barrierDismissible: true, variant: DialogType.askForHelp, - title: 'Ask for Help', - description: - "If you've already tried the Read-Search-Ask method, then you can try asking for help on the freeCodeCamp forum.", - mainButtonTitle: 'Create a post', + title: context.t.ask_for_help, + description: context.t.forum_help_description, + mainButtonTitle: context.t.forum_create_post, data: {'challengeName': challenge.title, 'blockName': block.name}); if (res != null && res.confirmed) { DialogResponse? forumRes = await _dialogService.showCustomDialog( variant: DialogType.askForHelpInput, - title: 'Create a post', - description: - 'You must confirm the following statements before you can submit your post to the forum.', - mainButtonTitle: 'Submit', - secondaryButtonTitle: 'Cancel', + title: context.t.forum_create_post, + description: context.t.forum_confirm_description, + mainButtonTitle: context.t.solution_link_submit, + secondaryButtonTitle: context.t.cancel, data: {'challengeName': challenge.title, 'blockName': block.name}, ); diff --git a/mobile-app/lib/service/navigation/quick_actions_service.dart b/mobile-app/lib/service/navigation/quick_actions_service.dart index a724c14a6..aa6401333 100644 --- a/mobile-app/lib/service/navigation/quick_actions_service.dart +++ b/mobile-app/lib/service/navigation/quick_actions_service.dart @@ -2,6 +2,8 @@ import 'dart:io'; import 'package:freecodecamp/app/app.locator.dart'; import 'package:freecodecamp/app/app.router.dart'; +import 'package:freecodecamp/l10n/app_localizations.dart'; +import 'package:freecodecamp/service/locale_service.dart'; import 'package:quick_actions/quick_actions.dart'; import 'package:stacked_services/stacked_services.dart'; @@ -18,27 +20,29 @@ class QuickActionsService { } Future init() async { + final t = lookupAppLocalizations(locator().locale); + await quickActions.setShortcutItems([ - const ShortcutItem( + ShortcutItem( type: 'action_daily_challenges', - localizedTitle: 'Daily Challenges', + localizedTitle: t.quick_action_daily_challenges, ), - const ShortcutItem( + ShortcutItem( type: 'action_learn', - localizedTitle: 'Learn', + localizedTitle: t.learn, ), - const ShortcutItem( + ShortcutItem( type: 'action_tutorials', - localizedTitle: 'Tutorials', + localizedTitle: t.tutorials, ), if (!Platform.isIOS) - const ShortcutItem( + ShortcutItem( type: 'action_code_radio', - localizedTitle: 'Code Radio', + localizedTitle: t.code_radio, ), - const ShortcutItem( + ShortcutItem( type: 'action_podcasts', - localizedTitle: 'Podcasts', + localizedTitle: t.podcasts, ), ]); diff --git a/mobile-app/lib/service/podcast/download_service.dart b/mobile-app/lib/service/podcast/download_service.dart index 7a7d10d8d..19842e7c8 100644 --- a/mobile-app/lib/service/podcast/download_service.dart +++ b/mobile-app/lib/service/podcast/download_service.dart @@ -4,9 +4,11 @@ import 'dart:io'; import 'package:dio/dio.dart'; import 'package:freecodecamp/app/app.locator.dart'; +import 'package:freecodecamp/l10n/app_localizations.dart'; import 'package:freecodecamp/models/podcasts/episodes_model.dart'; import 'package:freecodecamp/models/podcasts/podcasts_model.dart'; import 'package:freecodecamp/service/dio_service.dart'; +import 'package:freecodecamp/service/locale_service.dart'; import 'package:freecodecamp/service/podcast/notification_service.dart'; import 'package:freecodecamp/service/podcast/podcasts_service.dart'; import 'package:path_provider/path_provider.dart'; @@ -17,6 +19,7 @@ class DownloadService { static final DownloadService _downloadService = DownloadService._internal(); final _databaseService = locator(); final _notificationService = locator(); + final _localeService = locator(); static final StreamController _progStream = StreamController.broadcast(); @@ -58,8 +61,9 @@ class DownloadService { }, options: Options(headers: {'User-Agent': await userAgent()})); _downloading.sink.add(false); _progStream.sink.add(''); + final t = lookupAppLocalizations(_localeService.locale); await _notificationService.showNotification( - 'Download complete', + t.download_complete, '${podcast.title} - ${episode.title}', ); await _databaseService.addPodcast(podcast); diff --git a/mobile-app/lib/service/podcast/notification_service.dart b/mobile-app/lib/service/podcast/notification_service.dart index d40fd70a5..afe2827ae 100644 --- a/mobile-app/lib/service/podcast/notification_service.dart +++ b/mobile-app/lib/service/podcast/notification_service.dart @@ -4,6 +4,9 @@ import 'dart:math'; import 'package:flutter/services.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:freecodecamp/app/app.locator.dart'; +import 'package:freecodecamp/l10n/app_localizations.dart'; +import 'package:freecodecamp/service/locale_service.dart'; // This is a singleton class and initialized only once class NotificationService { @@ -11,6 +14,7 @@ class NotificationService { NotificationService._internal(); final FlutterLocalNotificationsPlugin _flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); + final LocaleService _localeService = locator(); Random random = Random(); factory NotificationService() { @@ -96,12 +100,14 @@ class NotificationService { Future requestPermissionIfNeeded() => requestPermission(); Future showNotification(String title, String body) async { + final t = lookupAppLocalizations(_localeService.locale); + // Check up more on the below values here - const AndroidNotificationDetails androidNotificationDetails = + final AndroidNotificationDetails androidNotificationDetails = AndroidNotificationDetails( 'fcc-notif-channel', 'podcast-episodes', - channelDescription: 'Channel description', + channelDescription: t.notification_channel_description, priority: Priority.high, importance: Importance.max, ); @@ -109,7 +115,7 @@ class NotificationService { const DarwinNotificationDetails iosNotificationDetails = DarwinNotificationDetails(threadIdentifier: 'fcc-ios-notif-channel'); - const NotificationDetails platformChannelSpecifics = NotificationDetails( + final NotificationDetails platformChannelSpecifics = NotificationDetails( android: androidNotificationDetails, iOS: iosNotificationDetails, ); diff --git a/mobile-app/lib/service/symbol_bar_service.dart b/mobile-app/lib/service/symbol_bar_service.dart new file mode 100644 index 000000000..fdda37116 --- /dev/null +++ b/mobile-app/lib/service/symbol_bar_service.dart @@ -0,0 +1,270 @@ +/// Symbol Bar Service +/// +/// Responsibilities: +/// - Load and save symbol bar state to SharedPreferences +/// - Switch between predefined symbol sets +/// - Create, update, delete custom symbol sets +/// - Provide reactive updates via listeners +/// - Handle backward compatibility with legacy hardcoded symbols +library; + +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:freecodecamp/models/symbol_set_model.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class SymbolBarService { + static const String _activeSetKey = 'symbol_bar_active_set'; + static const String _customSetKey = 'symbol_bar_custom_sets'; + static const String _customEnabledKey = 'symbol_bar_custom_enabled'; + + late SharedPreferences _prefs; + late SymbolBarState _state; + + /// List of listeners called when state changes + final List _listeners = []; + + /// Whether the service has been initialized + bool _initialized = false; + + /// Initialize the service and load persisted state + Future init(SharedPreferences prefs) async { + _prefs = prefs; + await _loadState(); + _initialized = true; + } + + /// Get current state + SymbolBarState get state => _state; + + /// Check if initialized + bool get isInitialized => _initialized; + + /// Add listener for state changes + void addListener(Function(SymbolBarState) listener) { + _listeners.add(listener); + } + + /// Remove listener + void removeListener(Function(SymbolBarState) listener) { + _listeners.remove(listener); + } + + /// Notify all listeners of state change + void _notifyListeners() { + for (var listener in _listeners) { + listener(_state); + } + } + + /// Load state from SharedPreferences + Future _loadState() async { + try { + final activeSetJson = _prefs.getString(_activeSetKey); + final customSetsJson = _prefs.getString(_customSetKey); + final customEnabled = _prefs.getBool(_customEnabledKey) ?? false; + + if (activeSetJson != null) { + // Deserialize from JSON + final activeSet = SymbolSet.fromJson( + jsonDecode(activeSetJson) as Map, + ); + + final customSymbolSets = []; + if (customSetsJson != null) { + final customList = jsonDecode(customSetsJson) as List; + customSymbolSets.addAll( + customList.cast>().map( + (json) => CustomSymbolSet.fromJson(json), + ), + ); + } + + _state = SymbolBarState( + activeSet: activeSet, + customSymbolsEnabled: customEnabled, + customSymbolSets: customSymbolSets, + ); + } else { + // First launch: use default state (Python set) + _state = SymbolBarState.defaultState(); + await _saveState(); + } + } catch (e) { + // If deserialization fails, reset to default + debugPrint('Error loading symbol bar state: $e'); + _state = SymbolBarState.defaultState(); + await _saveState(); + } + } + + /// Save state to SharedPreferences + Future _saveState() async { + try { + await _prefs.setString( + _activeSetKey, jsonEncode(_state.activeSet.toJson())); + await _prefs.setString( + _customSetKey, + jsonEncode(_state.customSymbolSets.map((s) => s.toJson()).toList()), + ); + await _prefs.setBool(_customEnabledKey, _state.customSymbolsEnabled); + _notifyListeners(); + } catch (e) { + debugPrint('Error loading symbol bar state: $e'); + } + } + + /// Switch to a predefined symbol set + Future switchToPredefinedSet(SymbolSetType type) async { + final newSet = PredefinedSymbolSet(type); + _state = _state.copyWith( + activeSet: newSet, + customSymbolsEnabled: false, + ); + await _saveState(); + } + + /// Switch to a custom symbol set by name + Future switchToCustomSet(String setName) async { + final customSet = _state.customSymbolSets.firstWhere( + (set) => set.name == setName, + orElse: () => throw Exception('Custom set not found: $setName'), + ); + _state = _state.copyWith( + activeSet: customSet, + customSymbolsEnabled: true, + ); + await _saveState(); + } + + /// Create a new custom symbol set + Future createCustomSet({ + required String name, + required List symbols, + }) async { + // Validate inputs + if (name.trim().isEmpty) { + throw ArgumentError('Custom set name cannot be empty'); + } + if (symbols.isEmpty) { + throw ArgumentError('Custom set must contain at least one symbol'); + } + if (symbols.length > 50) { + throw ArgumentError('Custom set cannot exceed 50 symbols'); + } + + // Check for duplicate names + if (_state.customSymbolSets.any((set) => set.name == name)) { + throw ArgumentError('Custom set with name "$name" already exists'); + } + + final newSet = CustomSymbolSet(name: name, symbols: symbols); + final newCustomSets = [..._state.customSymbolSets, newSet]; + + _state = _state.copyWith(customSymbolSets: newCustomSets); + await _saveState(); + } + + /// Update an existing custom symbol set + Future updateCustomSet({ + required String currentName, + String? newName, + List? newSymbols, + }) async { + final index = + _state.customSymbolSets.indexWhere((set) => set.name == currentName); + if (index == -1) { + throw Exception('Custom set not found: $currentName'); + } + + final currentSet = _state.customSymbolSets[index]; + final updatedName = newName ?? currentSet.name; + final updatedSymbols = newSymbols ?? currentSet.symbols; + + // Validate + if (updatedName.trim().isEmpty) { + throw ArgumentError('Custom set name cannot be empty'); + } + if (updatedSymbols.isEmpty) { + throw ArgumentError('Custom set must contain at least one symbol'); + } + if (updatedSymbols.length > 50) { + throw ArgumentError('Custom set cannot exceed 50 symbols'); + } + + // Check for duplicate names (excluding current set) + if (updatedName != currentName && + _state.customSymbolSets.any((set) => set.name == updatedName)) { + throw ArgumentError('Custom set with name "$updatedName" already exists'); + } + + final updatedSet = CustomSymbolSet( + name: updatedName, + symbols: updatedSymbols, + ); + + final newCustomSets = [..._state.customSymbolSets]; + newCustomSets[index] = updatedSet; + + // If this was the active set, update the active set reference + SymbolSet activeSet = _state.activeSet; + if (activeSet is CustomSymbolSet && activeSet.name == currentName) { + activeSet = updatedSet; + } + + _state = _state.copyWith( + activeSet: activeSet, + customSymbolSets: newCustomSets, + ); + await _saveState(); + } + + /// Delete a custom symbol set + Future deleteCustomSet(String setName) async { + final index = + _state.customSymbolSets.indexWhere((set) => set.name == setName); + if (index == -1) { + throw Exception('Custom set not found: $setName'); + } + + final newCustomSets = [..._state.customSymbolSets]; + newCustomSets.removeAt(index); + + // If this was the active set, switch back to Python + SymbolSet activeSet = _state.activeSet; + if (activeSet is CustomSymbolSet && activeSet.name == setName) { + activeSet = PredefinedSymbolSet(SymbolSetType.python); + } + + _state = _state.copyWith( + activeSet: activeSet, + customSymbolSets: newCustomSets, + ); + await _saveState(); + } + + /// Get all available symbol sets (predefined + custom) + List getAllSymbolSets() { + return [ + PredefinedSymbolSet(SymbolSetType.python), + PredefinedSymbolSet(SymbolSetType.javascript), + PredefinedSymbolSet(SymbolSetType.htmlCss), + ..._state.customSymbolSets, + ]; + } + + /// Reset custom symbol sets and switch to Python + Future resetToDefaults() async { + _state = SymbolBarState.defaultState(); + await _saveState(); + } + + /// Clear all data (useful for testing or factory reset) + Future clearAll() async { + await _prefs.remove(_activeSetKey); + await _prefs.remove(_customSetKey); + await _prefs.remove(_customEnabledKey); + _state = SymbolBarState.defaultState(); + _notifyListeners(); + } +} diff --git a/mobile-app/lib/ui/viewmodels/symbol_bar_viewmodel.dart b/mobile-app/lib/ui/viewmodels/symbol_bar_viewmodel.dart new file mode 100644 index 000000000..ec3602566 --- /dev/null +++ b/mobile-app/lib/ui/viewmodels/symbol_bar_viewmodel.dart @@ -0,0 +1,167 @@ +/// Symbol Bar ViewModel +/// +/// Responsible for managing the symbol bar UI state and interactions. +/// Uses Stacked MVVM pattern for reactive updates. +/// +/// Key Responsibilities: +/// - Reactive state management for symbol sets and custom symbols +/// - Handle user interactions (switching sets, creating/editing symbols) +/// - Provide getters for the view layer to observe +/// - Integrate with SymbolBarService for persistence +library; + +import 'package:freecodecamp/app/app.locator.dart'; +import 'package:freecodecamp/models/symbol_set_model.dart'; +import 'package:freecodecamp/service/symbol_bar_service.dart'; +import 'package:stacked/stacked.dart'; + +class SymbolBarViewModel extends BaseViewModel { + final SymbolBarService _symbolBarService = locator(); + + // State getters + SymbolBarState get state => _symbolBarService.state; + List get currentSymbols => _symbolBarService.state.currentSymbols; + SymbolSet get activeSet => _symbolBarService.state.activeSet; + List get customSymbolSets => + _symbolBarService.state.customSymbolSets; + bool get customSymbolsEnabled => _symbolBarService.state.customSymbolsEnabled; + + // All available symbol sets (predefined + custom) + List get allSymbolSets => _symbolBarService.getAllSymbolSets(); + + // Predefined symbol sets + List get predefinedSets => [ + PredefinedSymbolSet(SymbolSetType.python), + PredefinedSymbolSet(SymbolSetType.javascript), + PredefinedSymbolSet(SymbolSetType.htmlCss), + ]; + + /// Initialize the ViewModel and set up listeners + void init() { + _symbolBarService.addListener(_onSymbolBarStateChanged); + } + + /// Clean up resources + @override + void dispose() { + _symbolBarService.removeListener(_onSymbolBarStateChanged); + super.dispose(); + } + + /// Called whenever the symbol bar state changes + /// Triggers UI rebuild via notifyListeners() + void _onSymbolBarStateChanged(SymbolBarState newState) { + notifyListeners(); + } + + // ==================== PREDEFINED SETS ==================== + + /// Switch to a predefined symbol set + Future switchToPredefinedSet(SymbolSetType type) async { + await _symbolBarService.switchToPredefinedSet(type); + } + + // ==================== CUSTOM SETS ==================== + + /// Create a new custom symbol set + /// + /// Throws ArgumentError if: + /// - name is empty + /// - symbols list is empty or > 50 + /// - name already exists + Future createCustomSet({ + required String name, + required List symbols, + }) async { + try { + await _symbolBarService.createCustomSet( + name: name, + symbols: symbols, + ); + } catch (e) { + rethrow; + } + } + + /// Update an existing custom symbol set + /// + /// Throws Exception if set not found + Future updateCustomSet({ + required String currentName, + String? newName, + List? newSymbols, + }) async { + try { + await _symbolBarService.updateCustomSet( + currentName: currentName, + newName: newName, + newSymbols: newSymbols, + ); + } catch (e) { + rethrow; + } + } + + /// Delete a custom symbol set + /// + /// If it's currently active, switches to Python set + Future deleteCustomSet(String setName) async { + try { + await _symbolBarService.deleteCustomSet(setName); + } catch (e) { + rethrow; + } + } + + /// Switch to a custom symbol set by name + Future switchToCustomSet(String setName) async { + try { + await _symbolBarService.switchToCustomSet(setName); + } catch (e) { + rethrow; + } + } + + // ==================== UTILITIES ==================== + + /// Check if a custom set name already exists + bool customSetNameExists(String name) { + return customSymbolSets.any((set) => set.name == name); + } + + /// Get a custom set by name + CustomSymbolSet? getCustomSetByName(String name) { + try { + return customSymbolSets.firstWhere((set) => set.name == name); + } catch (e) { + return null; + } + } + + /// Reset all custom symbol sets and switch to Python + Future resetToDefaults() async { + await _symbolBarService.resetToDefaults(); + } + + /// Get display name for the active set + String getActiveSetDisplayName() { + if (activeSet is PredefinedSymbolSet) { + return (activeSet as PredefinedSymbolSet).type.displayName; + } else if (activeSet is CustomSymbolSet) { + return (activeSet as CustomSymbolSet).name; + } + return 'Unknown'; + } + + /// Validate symbol (not empty, reasonable length) + bool isValidSymbol(String symbol) { + return symbol.isNotEmpty && symbol.length <= 50; + } + + /// Validate symbol list + bool isValidSymbolList(List symbols) { + return symbols.isNotEmpty && + symbols.length <= 50 && + symbols.every(isValidSymbol); + } +} diff --git a/mobile-app/lib/ui/views/code_radio/code_radio_view.dart b/mobile-app/lib/ui/views/code_radio/code_radio_view.dart index 03bf1caa5..b3e028338 100644 --- a/mobile-app/lib/ui/views/code_radio/code_radio_view.dart +++ b/mobile-app/lib/ui/views/code_radio/code_radio_view.dart @@ -18,7 +18,7 @@ class CodeRadioView extends StatelessWidget { builder: (context, model, child) => Scaffold( backgroundColor: const Color(0xFF0a0a23), appBar: AppBar( - title: const Text('CODE RADIO'), + title: Text(context.t.code_radio), ), drawer: const DrawerWidgetView(), body: template(context, model), @@ -132,7 +132,7 @@ class CodeRadioView extends StatelessWidget { stream: model.audioService.playbackState.stream, builder: (context, snapshot) { if (snapshot.hasData) { - if(!snapshot.data.playing) { + if (!snapshot.data.playing) { model.stoppedManually = true; } return ElevatedButton.icon( diff --git a/mobile-app/lib/ui/views/learn/block/block_template_viewmodel.dart b/mobile-app/lib/ui/views/learn/block/block_template_viewmodel.dart index ad6537046..84d00a244 100644 --- a/mobile-app/lib/ui/views/learn/block/block_template_viewmodel.dart +++ b/mobile-app/lib/ui/views/learn/block/block_template_viewmodel.dart @@ -1,7 +1,9 @@ import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:freecodecamp/app/app.locator.dart'; import 'package:freecodecamp/app/app.router.dart'; +import 'package:freecodecamp/l10n/app_localizations.dart'; import 'package:freecodecamp/models/learn/completed_challenge_model.dart'; import 'package:freecodecamp/models/learn/curriculum_model.dart'; import 'package:freecodecamp/models/main/user_model.dart'; @@ -9,6 +11,7 @@ import 'package:freecodecamp/service/authentication/authentication_service.dart' import 'package:freecodecamp/service/developer_service.dart'; import 'package:freecodecamp/service/learn/learn_offline_service.dart'; import 'package:freecodecamp/service/learn/learn_service.dart'; +import 'package:freecodecamp/service/locale_service.dart'; import 'package:freecodecamp/ui/theme/fcc_theme.dart'; import 'package:freecodecamp/ui/views/learn/block/templates/dialogue/dialogue_view.dart'; import 'package:freecodecamp/ui/views/learn/block/templates/grid/grid_view.dart'; @@ -21,6 +24,7 @@ class BlockTemplateViewModel extends BaseViewModel { final NavigationService _navigationService = locator(); final AuthenticationService auth = locator(); final SnackbarService _snack = locator(); + final LocaleService _localeService = locator(); bool _isDev = false; bool get isDev => _isDev; @@ -40,8 +44,9 @@ class BlockTemplateViewModel extends BaseViewModel { } void showDisabledSnackbar() { + final t = lookupAppLocalizations(_localeService.locale); _snack.showSnackbar( - title: 'Temporarily unavailable, come back soon.', + title: t.temporarily_unavailable, message: '', ); Future.delayed(const Duration(milliseconds: 2500), () { diff --git a/mobile-app/lib/ui/views/learn/block/templates/dialogue/dialogue_view.dart b/mobile-app/lib/ui/views/learn/block/templates/dialogue/dialogue_view.dart index 8b8ddf4ad..c34d47fdc 100644 --- a/mobile-app/lib/ui/views/learn/block/templates/dialogue/dialogue_view.dart +++ b/mobile-app/lib/ui/views/learn/block/templates/dialogue/dialogue_view.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_html/flutter_html.dart'; import 'package:flutter_scroll_shadow/flutter_scroll_shadow.dart'; +import 'package:freecodecamp/extensions/i18n_extension.dart'; import 'package:freecodecamp/models/learn/curriculum_model.dart'; import 'package:freecodecamp/ui/theme/fcc_theme.dart'; import 'package:freecodecamp/ui/views/learn/block/block_template_viewmodel.dart'; @@ -86,7 +87,7 @@ class BlockDialogueView extends StatelessWidget { ), ), child: Text( - isOpen ? 'Hide Tasks' : 'Show Tasks', + isOpen ? context.t.hide_tasks : context.t.show_tasks, ), ), ), diff --git a/mobile-app/lib/ui/views/learn/block/templates/grid/grid_view.dart b/mobile-app/lib/ui/views/learn/block/templates/grid/grid_view.dart index 528699beb..494f66e42 100644 --- a/mobile-app/lib/ui/views/learn/block/templates/grid/grid_view.dart +++ b/mobile-app/lib/ui/views/learn/block/templates/grid/grid_view.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_html/flutter_html.dart'; import 'package:flutter_scroll_shadow/flutter_scroll_shadow.dart'; +import 'package:freecodecamp/extensions/i18n_extension.dart'; import 'package:freecodecamp/models/learn/curriculum_model.dart'; import 'package:freecodecamp/ui/theme/fcc_theme.dart'; import 'package:freecodecamp/ui/views/learn/block/block_template_viewmodel.dart'; @@ -57,7 +58,7 @@ class BlockGridView extends StatelessWidget { ), ), Text( - 'Completed', + context.t.completed, style: TextStyle( color: color, fontWeight: FontWeight.bold), ) @@ -111,7 +112,7 @@ class BlockGridView extends StatelessWidget { ), ), child: Text( - isOpen ? 'Hide Steps' : 'Show Steps', + isOpen ? context.t.hide_steps : context.t.show_steps, ), ), ), diff --git a/mobile-app/lib/ui/views/learn/block/templates/link/link_view.dart b/mobile-app/lib/ui/views/learn/block/templates/link/link_view.dart index 189dea8b0..efa8225ab 100644 --- a/mobile-app/lib/ui/views/learn/block/templates/link/link_view.dart +++ b/mobile-app/lib/ui/views/learn/block/templates/link/link_view.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_html/flutter_html.dart'; +import 'package:freecodecamp/extensions/i18n_extension.dart'; import 'package:freecodecamp/models/learn/curriculum_model.dart'; import 'package:freecodecamp/ui/theme/fcc_theme.dart'; import 'package:freecodecamp/ui/views/learn/block/block_template_viewmodel.dart'; @@ -44,7 +45,7 @@ class BlockLinkView extends StatelessWidget { ), ), Text( - 'Completed', + context.t.completed, style: TextStyle( color: color, fontWeight: FontWeight.bold), ) @@ -79,9 +80,9 @@ class BlockLinkView extends StatelessWidget { borderRadius: BorderRadius.circular(5), ), ), - child: const Text( - 'Start', - style: TextStyle( + child: Text( + context.t.start, + style: const TextStyle( fontWeight: FontWeight.bold, color: FccColors.gray90, ), diff --git a/mobile-app/lib/ui/views/learn/block/templates/list/list_view.dart b/mobile-app/lib/ui/views/learn/block/templates/list/list_view.dart index cdb1febe9..c2558392a 100644 --- a/mobile-app/lib/ui/views/learn/block/templates/list/list_view.dart +++ b/mobile-app/lib/ui/views/learn/block/templates/list/list_view.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_html/flutter_html.dart'; +import 'package:freecodecamp/extensions/i18n_extension.dart'; import 'package:freecodecamp/models/learn/curriculum_model.dart'; import 'package:freecodecamp/ui/theme/fcc_theme.dart'; import 'package:freecodecamp/ui/views/learn/block/block_template_viewmodel.dart'; @@ -50,7 +51,7 @@ class BlockListView extends StatelessWidget { ), ), Text( - 'Completed', + context.t.completed, style: TextStyle( color: Color.fromRGBO(0x99, 0xc9, 0xff, 1), fontWeight: FontWeight.bold), @@ -105,7 +106,7 @@ class BlockListView extends StatelessWidget { ), ), child: Text( - isOpen ? 'Hide Steps' : 'Show Steps', + isOpen ? context.t.hide_steps : context.t.show_steps, ), ), ), diff --git a/mobile-app/lib/ui/views/learn/challenge/challenge_view.dart b/mobile-app/lib/ui/views/learn/challenge/challenge_view.dart index 9a784064a..dc11d0456 100644 --- a/mobile-app/lib/ui/views/learn/challenge/challenge_view.dart +++ b/mobile-app/lib/ui/views/learn/challenge/challenge_view.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_html/flutter_html.dart'; import 'package:freecodecamp/enums/panel_type.dart'; +import 'package:freecodecamp/extensions/i18n_extension.dart'; import 'package:freecodecamp/models/learn/challenge_model.dart'; import 'package:freecodecamp/models/learn/curriculum_model.dart'; import 'package:freecodecamp/models/learn/daily_challenge_model.dart'; @@ -99,14 +100,14 @@ class ChallengeView extends StatelessWidget { ], title: (() { if (model.showPreview) { - return const Text( - 'PREVIEW', - style: TextStyle(fontWeight: FontWeight.bold), + return Text( + context.t.preview, + style: const TextStyle(fontWeight: FontWeight.bold), ); } else if (model.showConsole) { - return const Text( - 'CONSOLE', - style: TextStyle(fontWeight: FontWeight.bold), + return Text( + context.t.console, + style: const TextStyle(fontWeight: FontWeight.bold), ); } else { return Row( @@ -239,7 +240,7 @@ class ChallengeView extends StatelessWidget { ), const SizedBox(width: 8), Expanded( - child: _testsTabButton(model), + child: _testsTabButton(context, model), ), ], ), @@ -261,7 +262,7 @@ class ChallengeView extends StatelessWidget { children: [ if (keyboard) SymbolBar( - model: model, + challengeModel: model, editor: model.editor!, ), Row( @@ -298,8 +299,9 @@ class ChallengeView extends StatelessWidget { Icons.done_rounded, size: 30, ), - onPressed: - model.hasTypedInEditor ? model.runTests : null, + onPressed: model.hasTypedInEditor + ? () => model.runTests(context) + : null, splashColor: Colors.transparent, highlightColor: Colors.transparent, ), @@ -450,14 +452,14 @@ class ChallengeView extends StatelessWidget { ); } - Widget _testsTabButton(ChallengeViewModel model) { + Widget _testsTabButton(BuildContext context, ChallengeViewModel model) { return TextButton.icon( onPressed: () { model.setShowTestsPanel = !model.showTestsPanel; model.setMounted = false; }, label: Text( - 'Tests', + context.t.tests, style: TextStyle( color: model.showTestsPanel ? FccColors.blue50 : Colors.white, ), diff --git a/mobile-app/lib/ui/views/learn/challenge/challenge_viewmodel.dart b/mobile-app/lib/ui/views/learn/challenge/challenge_viewmodel.dart index a65f87b64..9a40b350a 100644 --- a/mobile-app/lib/ui/views/learn/challenge/challenge_viewmodel.dart +++ b/mobile-app/lib/ui/views/learn/challenge/challenge_viewmodel.dart @@ -513,7 +513,18 @@ class ChallengeViewModel extends BaseViewModel { }); } - // This function allows the symbols to be insterted into the text controllers + /// Insert a symbol at the current cursor position in the editor + /// + /// This method is called by the SymbolBar widget when a user clicks a symbol. + /// The SymbolBar widget displays symbols from SymbolBarService, which manages + /// predefined and custom symbol sets. This method handles the actual text insertion. + /// + /// Architecture: SymbolBarService (state/persistence) → SymbolBar widget (UI) → + /// insertSymbol (insertion logic) + /// + /// Parameters: + /// - [symbol]: The symbol string to insert (can be multi-character like "=>") + /// - [editor]: The Editor instance where the symbol should be inserted void insertSymbol(String symbol, Editor editor) async { final TextEditingControllerIDE focused = textFieldData!.controller; final RegionPosition position = textFieldData!.position; @@ -767,7 +778,7 @@ class ChallengeViewModel extends BaseViewModel { '--fcc-actual--', (failedTestErr?['actual'] ?? '').toString()); } - void runTests() async { + void runTests(BuildContext context) async { setShowPanel = false; setIsRunningTests = true; _failedTestIndexes = []; @@ -790,7 +801,11 @@ class ChallengeViewModel extends BaseViewModel { if (errorMessage.contains('Babel transpilation failed')) { errorMessage = errorMessage.replaceFirst('Exception: ', ''); } - String userFriendlyMessage = parseSyntaxError(errorMessage); + String userFriendlyMessage = parseSyntaxError( + errorMessage, + lineMessage: context.t.syntax_error_line, + generalMessage: context.t.syntax_error_general, + ); setPanelType = PanelType.hint; setHint = '

$userFriendlyMessage

'; diff --git a/mobile-app/lib/ui/views/learn/challenge/templates/english/english_view.dart b/mobile-app/lib/ui/views/learn/challenge/templates/english/english_view.dart index e54bbd78a..61e0d37c3 100644 --- a/mobile-app/lib/ui/views/learn/challenge/templates/english/english_view.dart +++ b/mobile-app/lib/ui/views/learn/challenge/templates/english/english_view.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_html/flutter_html.dart'; +import 'package:freecodecamp/extensions/i18n_extension.dart'; import 'package:freecodecamp/models/learn/challenge_model.dart'; import 'package:freecodecamp/models/learn/curriculum_model.dart'; import 'package:freecodecamp/ui/theme/fcc_theme.dart'; @@ -63,7 +64,7 @@ class EnglishView extends StatelessWidget { ), if (challenge.scene != null) ...[ ChallengeCard( - title: 'Scene', + title: context.t.challenge_card_scene, child: SceneView( scene: challenge.scene!, ), @@ -71,7 +72,7 @@ class EnglishView extends StatelessWidget { ], if (challenge.fillInTheBlank != null) ChallengeCard( - title: 'Fill in the Blank', + title: context.t.challenge_card_fill_blank, child: Padding( padding: const EdgeInsets.all(10.0), child: Column( @@ -84,7 +85,7 @@ class EnglishView extends StatelessWidget { ), if (model.feedback.isNotEmpty) ChallengeCard( - title: 'Feedback', + title: context.t.challenge_card_feedback, child: Column( children: parser.parse(model.feedback), ), @@ -92,7 +93,7 @@ class EnglishView extends StatelessWidget { if (challenge.explanation != null && challenge.explanation!.isNotEmpty) ...[ ChallengeCard( - title: 'Explanation', + title: context.t.challenge_card_explanation, child: Explanation( explanation: challenge.explanation ?? '', ), @@ -126,8 +127,8 @@ class EnglishView extends StatelessWidget { child: Text( model.allInputsCorrect || challenge.fillInTheBlank == null - ? 'Go to Next Challenge' - : 'Check Answers', + ? context.t.go_to_next_challenge + : context.t.check_answers, style: const TextStyle(fontSize: 20), ), ), @@ -158,9 +159,9 @@ class EnglishView extends StatelessWidget { block, context, ), - child: const Text( - 'Ask for Help', - style: TextStyle(fontSize: 20), + child: Text( + context.t.ask_for_help, + style: const TextStyle(fontSize: 20), ), ), ), diff --git a/mobile-app/lib/ui/views/learn/challenge/templates/multiple_choice/multiple_choice_view.dart b/mobile-app/lib/ui/views/learn/challenge/templates/multiple_choice/multiple_choice_view.dart index 540d58c5b..293d486bd 100644 --- a/mobile-app/lib/ui/views/learn/challenge/templates/multiple_choice/multiple_choice_view.dart +++ b/mobile-app/lib/ui/views/learn/challenge/templates/multiple_choice/multiple_choice_view.dart @@ -54,7 +54,7 @@ class MultipleChoiceView extends StatelessWidget { children: [ if (challenge.description.isNotEmpty) ChallengeCard( - title: 'Description', + title: context.t.challenge_card_description, child: Column( children: parser.parse( challenge.description, @@ -68,7 +68,7 @@ class MultipleChoiceView extends StatelessWidget { ), if (challenge.videoId != null) ...[ ChallengeCard( - title: 'Watch the Video', + title: context.t.challenge_card_watch_video, child: YoutubePlayerWidget( videoId: challenge.videoId!, ), @@ -76,7 +76,7 @@ class MultipleChoiceView extends StatelessWidget { ], if (challenge.transcript.isNotEmpty) ...[ ChallengeCard( - title: 'Transcript', + title: context.t.challenge_card_transcript, child: Transcript( transcript: challenge.transcript, isCollapsible: challenge.videoId != null, @@ -85,14 +85,14 @@ class MultipleChoiceView extends StatelessWidget { ], if (challenge.scene != null) ...[ ChallengeCard( - title: 'Scene', + title: context.t.challenge_card_scene, child: SceneView( scene: challenge.scene!, ), ), ] else if (challenge.audio != null) ...[ ChallengeCard( - title: 'Listen to the Audio', + title: context.t.challenge_card_listen_audio, child: AudioPlayerView( audio: challenge.audio!, ), @@ -100,7 +100,7 @@ class MultipleChoiceView extends StatelessWidget { ], if (challenge.instructions.isNotEmpty) ChallengeCard( - title: 'Instructions', + title: context.t.instructions, child: Column( children: parser.parse( challenge.instructions, @@ -110,7 +110,7 @@ class MultipleChoiceView extends StatelessWidget { if (challenge.assignments != null && challenge.assignments!.isNotEmpty) ...[ ChallengeCard( - title: 'Assignments', + title: context.t.challenge_card_assignments, child: Column( children: [ for (final (i, assignment) @@ -138,7 +138,7 @@ class MultipleChoiceView extends StatelessWidget { if (challenge.explanation != null && challenge.explanation!.isNotEmpty) ...[ ChallengeCard( - title: 'Explanation', + title: context.t.challenge_card_explanation, child: Explanation( explanation: challenge.explanation ?? '', ), @@ -185,7 +185,8 @@ class MultipleChoiceView extends StatelessWidget { block, ); } else { - model.validateChallenge(); + model.validateChallenge( + context.t.some_answers_are_incorrect); } } : null, @@ -222,9 +223,9 @@ class MultipleChoiceView extends StatelessWidget { block, context, ), - child: const Text( - 'Ask for Help', - style: TextStyle(fontSize: 20), + child: Text( + context.t.ask_for_help, + style: const TextStyle(fontSize: 20), ), ), ), @@ -240,5 +241,3 @@ class MultipleChoiceView extends StatelessWidget { ); } } - - diff --git a/mobile-app/lib/ui/views/learn/challenge/templates/multiple_choice/multiple_choice_viewmodel.dart b/mobile-app/lib/ui/views/learn/challenge/templates/multiple_choice/multiple_choice_viewmodel.dart index 84fb9788f..ccebf5e9c 100644 --- a/mobile-app/lib/ui/views/learn/challenge/templates/multiple_choice/multiple_choice_viewmodel.dart +++ b/mobile-app/lib/ui/views/learn/challenge/templates/multiple_choice/multiple_choice_viewmodel.dart @@ -82,7 +82,7 @@ class MultipleChoiceViewmodel extends BaseViewModel { notifyListeners(); } - void validateChallenge() { + void validateChallenge(String incorrectAnswersMessage) { // Loop through each question and set isCorrect status setQuizQuestions = List.from(quizQuestions) ..asMap().forEach((i, question) { @@ -97,8 +97,7 @@ class MultipleChoiceViewmodel extends BaseViewModel { // Show the error message if there are multiple questions. // Otherwise, the validation message is sufficient. if (quizQuestions.length > 1) { - setErrMessage = - hasPassedAllQuestions ? '' : 'Some answers are incorrect.'; + setErrMessage = hasPassedAllQuestions ? '' : incorrectAnswersMessage; } } } diff --git a/mobile-app/lib/ui/views/learn/challenge/templates/python-project/python_project_view.dart b/mobile-app/lib/ui/views/learn/challenge/templates/python-project/python_project_view.dart index dd2f8f9e2..ed6c8b94b 100644 --- a/mobile-app/lib/ui/views/learn/challenge/templates/python-project/python_project_view.dart +++ b/mobile-app/lib/ui/views/learn/challenge/templates/python-project/python_project_view.dart @@ -55,7 +55,7 @@ class PythonProjectView extends StatelessWidget { controller: model.linkController, onChanged: (value) => model.setValidLink = null, decoration: InputDecoration( - hintText: 'ex: https://replit.com/@camperbot/hello', + hintText: context.t.project_link_hint, errorText: model.validLink != null && !model.validLink! ? model.linkErrMsg : null, @@ -118,7 +118,16 @@ class PythonProjectView extends StatelessWidget { challenge, block, ) - : model.checkLink(); + : model.checkLink( + invalidLinkMessage: + context.t.project_link_invalid, + ownWorkMessage: + context.t.project_link_own_work, + insecureUrlMessage: + context.t.project_link_insecure, + publicUrlMessage: + context.t.project_link_public_url, + ); }, child: Text( model.validLink != null && model.validLink! diff --git a/mobile-app/lib/ui/views/learn/challenge/templates/python-project/python_project_viewmodel.dart b/mobile-app/lib/ui/views/learn/challenge/templates/python-project/python_project_viewmodel.dart index ff6400912..504fc3ecf 100644 --- a/mobile-app/lib/ui/views/learn/challenge/templates/python-project/python_project_viewmodel.dart +++ b/mobile-app/lib/ui/views/learn/challenge/templates/python-project/python_project_viewmodel.dart @@ -39,22 +39,27 @@ class PythonProjectViewModel extends BaseViewModel { } } - void checkLink() { + void checkLink({ + required String invalidLinkMessage, + required String ownWorkMessage, + required String insecureUrlMessage, + required String publicUrlMessage, + }) { if (!isUrl(linkController.text)) { setValidLink = false; - setLinkErrMsg = 'Please enter a valid link.'; + setLinkErrMsg = invalidLinkMessage; return; } else if (fCCRegex.hasMatch(linkController.text)) { setValidLink = false; - setLinkErrMsg = 'Remember to submit your own work.'; + setLinkErrMsg = ownWorkMessage; return; } else if (httpRegex.hasMatch(linkController.text)) { setValidLink = false; - setLinkErrMsg = 'An unsecure (http) URL cannot be used.'; + setLinkErrMsg = insecureUrlMessage; return; } else if (localhostRegex.hasMatch(linkController.text)) { setValidLink = false; - setLinkErrMsg = 'Remember to submit a publicly visible app URL.'; + setLinkErrMsg = publicUrlMessage; return; } else { setValidLink = true; diff --git a/mobile-app/lib/ui/views/learn/challenge/templates/python/python_view.dart b/mobile-app/lib/ui/views/learn/challenge/templates/python/python_view.dart index 5879b3e7e..266c52da1 100644 --- a/mobile-app/lib/ui/views/learn/challenge/templates/python/python_view.dart +++ b/mobile-app/lib/ui/views/learn/challenge/templates/python/python_view.dart @@ -57,7 +57,7 @@ class PythonView extends StatelessWidget { const SizedBox(height: 12), if (challenge.description.isNotEmpty) ChallengeCard( - title: 'Description', + title: context.t.challenge_card_description, child: Align( alignment: Alignment.centerLeft, child: Column( @@ -70,7 +70,7 @@ class PythonView extends StatelessWidget { const SizedBox(height: 12), if (challenge.videoId != null) ChallengeCard( - title: 'Video', + title: context.t.challenge_card_video, child: YoutubePlayerWidget( videoId: challenge.videoId!, ), @@ -78,7 +78,7 @@ class PythonView extends StatelessWidget { const SizedBox(height: 12), if (challenge.transcript.isNotEmpty) ...[ ChallengeCard( - title: 'Transcript', + title: context.t.challenge_card_transcript, child: Transcript( transcript: challenge.transcript, isCollapsible: challenge.videoId != null, @@ -87,7 +87,7 @@ class PythonView extends StatelessWidget { ], if (challenge.instructions.isNotEmpty) ChallengeCard( - title: 'Instructions', + title: context.t.instructions, child: Column( children: parser.parse( challenge.instructions, @@ -139,7 +139,8 @@ class PythonView extends StatelessWidget { block, ); } else { - model.validateChallenge(); + model.validateChallenge( + context.t.some_answers_are_incorrect); } } : null, @@ -176,9 +177,9 @@ class PythonView extends StatelessWidget { block, context, ), - child: const Text( - 'Ask for Help', - style: TextStyle(fontSize: 20), + child: Text( + context.t.ask_for_help, + style: const TextStyle(fontSize: 20), ), ), ), diff --git a/mobile-app/lib/ui/views/learn/challenge/templates/python/python_viewmodel.dart b/mobile-app/lib/ui/views/learn/challenge/templates/python/python_viewmodel.dart index 4ed52195c..44472df58 100644 --- a/mobile-app/lib/ui/views/learn/challenge/templates/python/python_viewmodel.dart +++ b/mobile-app/lib/ui/views/learn/challenge/templates/python/python_viewmodel.dart @@ -63,7 +63,7 @@ class PythonViewModel extends BaseViewModel { notifyListeners(); } - void validateChallenge() { + void validateChallenge(String incorrectAnswersMessage) { // Loop through each question and set isCorrect status setQuizQuestions = List.from(quizQuestions) ..asMap().forEach((i, question) { @@ -78,8 +78,7 @@ class PythonViewModel extends BaseViewModel { // Show the error message if there are multiple questions. // Otherwise, the validation message is sufficient. if (quizQuestions.length > 1) { - setErrMessage = - hasPassedAllQuestions ? '' : 'Some answers are incorrect.'; + setErrMessage = hasPassedAllQuestions ? '' : incorrectAnswersMessage; } } } diff --git a/mobile-app/lib/ui/views/learn/challenge/templates/quiz/quiz_view.dart b/mobile-app/lib/ui/views/learn/challenge/templates/quiz/quiz_view.dart index 513a808bc..e269520de 100644 --- a/mobile-app/lib/ui/views/learn/challenge/templates/quiz/quiz_view.dart +++ b/mobile-app/lib/ui/views/learn/challenge/templates/quiz/quiz_view.dart @@ -39,25 +39,24 @@ class QuizView extends StatelessWidget { final shouldPop = await showDialog( context: context, builder: (context) => AlertDialog( - title: const Text('Are you sure?'), - content: const Text( - 'Do you want to leave this quiz? Your progress will be lost.'), + title: Text(context.t.quiz_leave_title), + content: Text(context.t.quiz_leave_message), actions: [ TextButton( style: TextButton.styleFrom( backgroundColor: FccColors.gray80, ), onPressed: () => Navigator.of(context).pop(false), - child: const Text('Cancel'), + child: Text(context.t.cancel), ), TextButton( style: TextButton.styleFrom( backgroundColor: FccColors.red15, ), onPressed: () => Navigator.of(context).pop(true), - child: const Text( - 'Leave', - style: TextStyle(color: FccColors.red90), + child: Text( + context.t.leave, + style: const TextStyle(color: FccColors.red90), ), ), ], @@ -169,7 +168,7 @@ class QuizView extends StatelessWidget { !model.hasPassedAllQuestions) { model.resetQuiz(); } else { - model.validateChallenge(); + model.validateChallenge(context.t); } }, child: Text( diff --git a/mobile-app/lib/ui/views/learn/challenge/templates/quiz/quiz_viewmodel.dart b/mobile-app/lib/ui/views/learn/challenge/templates/quiz/quiz_viewmodel.dart index 6257d85eb..77787775c 100644 --- a/mobile-app/lib/ui/views/learn/challenge/templates/quiz/quiz_viewmodel.dart +++ b/mobile-app/lib/ui/views/learn/challenge/templates/quiz/quiz_viewmodel.dart @@ -1,4 +1,5 @@ import 'package:freecodecamp/app/app.locator.dart'; +import 'package:freecodecamp/l10n/app_localizations.dart'; import 'package:freecodecamp/models/learn/challenge_model.dart'; import 'package:freecodecamp/service/learn/learn_service.dart'; import 'package:freecodecamp/ui/views/learn/widgets/quiz_widget.dart'; @@ -81,10 +82,10 @@ class QuizViewModel extends BaseViewModel { notifyListeners(); } - void validateChallenge() { + void validateChallenge(AppLocalizations t) { if (unansweredQuestions.length > 1) { setErrMessage = - "The following questions are unanswered: ${unansweredQuestions.join(', ')}. You must answer all questions."; + t.quiz_unanswered_questions(unansweredQuestions.join(', ')); return; } @@ -105,8 +106,8 @@ class QuizViewModel extends BaseViewModel { setIsValidated = true; setErrMessage = hasPassedAllQuestions - ? '✅ You have $correctQuestionsCount out of $totalQuestions questions correct. You have passed.' - : "❌ You have $correctQuestionsCount out of $totalQuestions questions correct. You didn't pass."; + ? '✅ ${t.quiz_passed(correctQuestionsCount, totalQuestions)}' + : '❌ ${t.quiz_failed(correctQuestionsCount, totalQuestions)}'; } void resetQuiz() { diff --git a/mobile-app/lib/ui/views/learn/challenge/templates/review/review_view.dart b/mobile-app/lib/ui/views/learn/challenge/templates/review/review_view.dart index 70d0fc8e3..c33d948f3 100644 --- a/mobile-app/lib/ui/views/learn/challenge/templates/review/review_view.dart +++ b/mobile-app/lib/ui/views/learn/challenge/templates/review/review_view.dart @@ -59,7 +59,7 @@ class ReviewView extends StatelessWidget { if (challenge.videoId != null) ...[ const SizedBox(height: 12), ChallengeCard( - title: 'Video', + title: context.t.challenge_card_video, child: YoutubePlayerWidget( videoId: challenge.videoId!, ), @@ -68,14 +68,14 @@ class ReviewView extends StatelessWidget { ], if (challenge.scene != null) ...[ ChallengeCard( - title: 'Scene', + title: context.t.challenge_card_scene, child: SceneView( scene: challenge.scene!, ), ), ] else if (challenge.audio != null) ...[ ChallengeCard( - title: 'Listen to the Audio', + title: context.t.challenge_card_listen_audio, child: AudioPlayerView( audio: challenge.audio!, ), @@ -83,7 +83,7 @@ class ReviewView extends StatelessWidget { ], if (challenge.transcript.isNotEmpty) ...[ ChallengeCard( - title: 'Transcript', + title: context.t.challenge_card_transcript, child: Transcript( transcript: challenge.transcript, isCollapsible: challenge.videoId != null, @@ -91,7 +91,7 @@ class ReviewView extends StatelessWidget { ), ], ChallengeCard( - title: 'Assignments', + title: context.t.challenge_card_assignments, child: Column( children: [ for (final (i, assignment) diff --git a/mobile-app/lib/ui/views/learn/challenge/templates/template_view.dart b/mobile-app/lib/ui/views/learn/challenge/templates/template_view.dart index 313921dd1..b998a6cd6 100644 --- a/mobile-app/lib/ui/views/learn/challenge/templates/template_view.dart +++ b/mobile-app/lib/ui/views/learn/challenge/templates/template_view.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:freecodecamp/extensions/i18n_extension.dart'; import 'package:freecodecamp/models/learn/challenge_model.dart'; import 'package:freecodecamp/models/learn/curriculum_model.dart'; import 'package:freecodecamp/ui/views/learn/challenge/challenge_view.dart'; @@ -99,7 +100,9 @@ class ChallengeTemplateView extends StatelessWidget { default: return Center( child: Text( - 'Unknown Challenge, info : ${challenge.challengeType}', + context.t.unknown_challenge( + challenge.challengeType.toString(), + ), ), ); } @@ -108,7 +111,10 @@ class ChallengeTemplateView extends StatelessWidget { if (snapshot.hasError) { return Center( child: Text( - 'Error: ${snapshot.error}\n${snapshot.stackTrace}', + context.t.challenge_error( + snapshot.error.toString(), + snapshot.stackTrace.toString(), + ), ), ); } diff --git a/mobile-app/lib/ui/views/learn/chapter/chapter_view.dart b/mobile-app/lib/ui/views/learn/chapter/chapter_view.dart index 6f8f5f90a..5b2c8696e 100644 --- a/mobile-app/lib/ui/views/learn/chapter/chapter_view.dart +++ b/mobile-app/lib/ui/views/learn/chapter/chapter_view.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:freecodecamp/extensions/i18n_extension.dart'; import 'package:freecodecamp/models/learn/curriculum_model.dart'; import 'package:freecodecamp/ui/theme/fcc_theme.dart'; import 'package:freecodecamp/ui/views/learn/chapter/chapter_viewmodel.dart'; @@ -37,7 +38,11 @@ class ChapterView extends StatelessWidget { if (snapshot.hasError) { return Center( child: Text( - 'Error loading chapters: ${snapshot.error} ${snapshot.stackTrace}'), + context.t.error_loading_chapters( + snapshot.error.toString(), + snapshot.stackTrace.toString(), + ), + ), ); } @@ -56,8 +61,8 @@ class ChapterView extends StatelessWidget { .toList(); if (chapters.isEmpty) { - return const Center( - child: Text('No blocks available right now.'), + return Center( + child: Text(context.t.no_blocks_available), ); } @@ -133,7 +138,7 @@ class ChapterView extends StatelessWidget { width: 2, ), borderRadius: BorderRadius.circular(5)), - child: Text('Coming Soon'), + child: Text(context.t.coming_soon), ), for (Module module in chapter.modules as List) chapterButton(context, module, model, chapter) @@ -231,7 +236,7 @@ class ChapterView extends StatelessWidget { Padding( padding: const EdgeInsets.only(top: 4.0), child: Text( - 'Certification Project', + context.t.certification_project, style: TextStyle( color: moduleColor.withValues(alpha: 0.8), fontSize: 14, @@ -243,7 +248,7 @@ class ChapterView extends StatelessWidget { Padding( padding: const EdgeInsets.only(top: 4.0), child: Text( - 'Review', + context.t.review, style: TextStyle( color: moduleColor.withValues(alpha: 0.8), fontSize: 14, @@ -258,7 +263,7 @@ class ChapterView extends StatelessWidget { String steps = snapshot.data ?? '0'; return Text( - '$steps Steps Complete', + context.t.steps_complete(steps), style: TextStyle( color: FccColors.gray15, fontSize: 18, diff --git a/mobile-app/lib/ui/views/learn/daily_challenge/daily_challenge_view.dart b/mobile-app/lib/ui/views/learn/daily_challenge/daily_challenge_view.dart index 32a4dd419..6fd087d8f 100644 --- a/mobile-app/lib/ui/views/learn/daily_challenge/daily_challenge_view.dart +++ b/mobile-app/lib/ui/views/learn/daily_challenge/daily_challenge_view.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_html/flutter_html.dart'; +import 'package:freecodecamp/extensions/i18n_extension.dart'; import 'package:freecodecamp/models/learn/daily_challenge_model.dart'; import 'package:freecodecamp/ui/theme/fcc_theme.dart'; import 'package:freecodecamp/ui/views/learn/daily_challenge/daily_challenge_viewmodel.dart'; @@ -24,7 +25,7 @@ class DailyChallengeView extends StatelessWidget { onPressed: () => Scaffold.of(context).openDrawer(), ), ), - title: const Text('Daily Coding Challenges'), + title: Text(context.t.daily_coding_challenges), ), drawer: const DrawerWidgetView(), body: model.isBusy @@ -45,10 +46,10 @@ class DailyChallengeView extends StatelessWidget { }, ), ) - : const Center( + : Center( child: Text( - 'No challenges available at the moment.', - style: TextStyle(fontSize: 16), + context.t.daily_challenge_empty, + style: const TextStyle(fontSize: 16), textAlign: TextAlign.center, ), ), @@ -124,7 +125,7 @@ class DailyChallengeView extends StatelessWidget { ), ), Text( - 'Completed', + context.t.completed, style: TextStyle( color: FccColors.blue30, fontWeight: FontWeight.bold, @@ -173,7 +174,9 @@ class DailyChallengeView extends StatelessWidget { ), ), child: Text( - isOpen ? 'Hide Challenges' : 'Show Challenges', + isOpen + ? context.t.hide_challenges + : context.t.show_challenges, ), ), ), @@ -211,8 +214,14 @@ class DailyChallengeView extends StatelessWidget { }, child: Semantics( label: isCompleted - ? 'Challenge ${challenge.challengeNumber}: ${challenge.title}, completed' - : 'Challenge ${challenge.challengeNumber}: ${challenge.title}, not completed', + ? context.t.daily_challenge_item_completed_label( + challenge.challengeNumber, + challenge.title, + ) + : context.t.daily_challenge_item_not_completed_label( + challenge.challengeNumber, + challenge.title, + ), button: true, child: Card( shape: RoundedRectangleBorder( @@ -243,7 +252,10 @@ class DailyChallengeView extends StatelessWidget { ), Expanded( child: Text( - 'Challenge ${challenge.challengeNumber}: ${challenge.title}', + context.t.daily_challenge_item_title( + challenge.challengeNumber, + challenge.title, + ), style: TextStyle(fontSize: 18), ), ), diff --git a/mobile-app/lib/ui/views/learn/daily_challenge/daily_challenge_viewmodel.dart b/mobile-app/lib/ui/views/learn/daily_challenge/daily_challenge_viewmodel.dart index 401927087..b389702cd 100644 --- a/mobile-app/lib/ui/views/learn/daily_challenge/daily_challenge_viewmodel.dart +++ b/mobile-app/lib/ui/views/learn/daily_challenge/daily_challenge_viewmodel.dart @@ -1,11 +1,13 @@ import 'package:freecodecamp/app/app.locator.dart'; import 'package:freecodecamp/app/app.router.dart'; +import 'package:freecodecamp/l10n/app_localizations.dart'; import 'package:freecodecamp/models/learn/completed_challenge_model.dart'; import 'package:freecodecamp/models/learn/curriculum_model.dart'; import 'package:freecodecamp/models/learn/daily_challenge_model.dart'; import 'package:freecodecamp/models/main/user_model.dart'; import 'package:freecodecamp/service/authentication/authentication_service.dart'; import 'package:freecodecamp/service/learn/daily_challenge_service.dart'; +import 'package:freecodecamp/service/locale_service.dart'; import 'package:freecodecamp/ui/views/learn/utils/challenge_utils.dart'; import 'package:stacked/stacked.dart'; import 'package:stacked_services/stacked_services.dart'; @@ -15,6 +17,7 @@ class DailyChallengeViewModel extends BaseViewModel { final AuthenticationService _auth = locator(); final DailyChallengeService _dailyChallengeService = locator(); + final LocaleService _localeService = locator(); List _blocks = []; List get blocks => _blocks; @@ -73,6 +76,7 @@ class DailyChallengeViewModel extends BaseViewModel { _blocks = []; _blockOpenStates = {}; + final t = lookupAppLocalizations(_localeService.locale); // Create challenge groups for each month for (String monthYear in sortedMonths) { @@ -87,8 +91,7 @@ class DailyChallengeViewModel extends BaseViewModel { final block = DailyChallengeBlock( monthYear: monthYear, challenges: monthChallenges, - description: - 'Explore the daily coding challenges for $monthYear. Stay motivated and keep your learning streak alive!', + description: t.daily_challenge_month_description(monthYear), ); _blocks.add(block); @@ -123,13 +126,13 @@ class DailyChallengeViewModel extends BaseViewModel { Future navigateToDailyChallenge( DailyChallengeOverview challenge) async { String monthYear = formatMonthFromDate(challenge.date); + final t = lookupAppLocalizations(_localeService.locale); // Map to curriculum block so ChallengeTemplateView can use it Block block = DailyChallengeBlock( monthYear: monthYear, challenges: [challenge], - description: - 'Explore the daily coding challenges for $monthYear. Stay motivated and keep your learning streak alive!', + description: t.daily_challenge_month_description(monthYear), ).toCurriculumBlock(); _navigationService.navigateTo( diff --git a/mobile-app/lib/ui/views/learn/landing/landing_view.dart b/mobile-app/lib/ui/views/learn/landing/landing_view.dart index 0bed60f84..b7813fe14 100644 --- a/mobile-app/lib/ui/views/learn/landing/landing_view.dart +++ b/mobile-app/lib/ui/views/learn/landing/landing_view.dart @@ -21,7 +21,7 @@ class LearnLandingView extends StatelessWidget { builder: (context, model, child) => Scaffold( backgroundColor: FccColors.gray90, appBar: AppBar( - title: Text('LEARN'), + title: Text(context.t.learn), ), drawer: const DrawerWidgetView( key: Key('drawer'), @@ -165,7 +165,7 @@ class LearnLandingView extends StatelessWidget { return Text( context.t.login_welcome_back( - user.username.startsWith('fcc') ? 'User' : user.username, + user.username.startsWith('fcc') ? context.t.user : user.username, ), textAlign: TextAlign.center, style: const TextStyle( @@ -256,70 +256,70 @@ class SuperBlockButton extends StatelessWidget { vertical: 6, horizontal: 4, ), - constraints: BoxConstraints( - minHeight: 80, - ), - child: ElevatedButton( - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.all(5), - backgroundColor: FccColors.gray80, - side: const BorderSide( - width: 2, - color: FccColors.gray75, - ), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(5)), + constraints: BoxConstraints( + minHeight: 80, + ), + child: ElevatedButton( + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.all(5), + backgroundColor: FccColors.gray80, side: const BorderSide( - color: Colors.teal, - width: 2.0, + width: 2, + color: FccColors.gray75, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(5)), + side: const BorderSide( + color: Colors.teal, + width: 2.0, + ), ), ), - ), - onPressed: () { - button.public - ? model.routeToSuperBlock(button.path, button.name) - : model.disabledButtonSnack(button.disabledByManualOverride); - }, - child: Row( - children: [ - Padding( - padding: const EdgeInsets.all(12.0), - child: SizedBox( - width: 36, - height: 36, - child: SvgPicture.asset( - iconMap[SuperBlocks.fromValue(button.path)] ?? - '${SuperBlockButton.learnAssetsPath}/graduation.svg', - fit: BoxFit.contain, - colorFilter: ColorFilter.mode( - FccColors.gray00, - BlendMode.srcIn, + onPressed: () { + button.public + ? model.routeToSuperBlock(button.path, button.name) + : model.disabledButtonSnack(button.disabledByManualOverride); + }, + child: Row( + children: [ + Padding( + padding: const EdgeInsets.all(12.0), + child: SizedBox( + width: 36, + height: 36, + child: SvgPicture.asset( + iconMap[SuperBlocks.fromValue(button.path)] ?? + '${SuperBlockButton.learnAssetsPath}/graduation.svg', + fit: BoxFit.contain, + colorFilter: ColorFilter.mode( + FccColors.gray00, + BlendMode.srcIn, + ), ), ), ), - ), - Expanded( - flex: 8, - child: Text( - button.name, - textAlign: TextAlign.left, - style: const TextStyle(fontSize: 20), - ), - ), - const Expanded( - flex: 2, - child: Align( - alignment: Alignment.centerRight, - child: Padding( - padding: EdgeInsets.symmetric(vertical: 8, horizontal: 12), - child: Icon(Icons.arrow_forward_ios), + Expanded( + flex: 8, + child: Text( + button.name, + textAlign: TextAlign.left, + style: const TextStyle(fontSize: 20), ), ), - ) - ], + const Expanded( + flex: 2, + child: Align( + alignment: Alignment.centerRight, + child: Padding( + padding: EdgeInsets.symmetric(vertical: 8, horizontal: 12), + child: Icon(Icons.arrow_forward_ios), + ), + ), + ) + ], + ), ), ), - ), ); } } diff --git a/mobile-app/lib/ui/views/learn/landing/landing_viewmodel.dart b/mobile-app/lib/ui/views/learn/landing/landing_viewmodel.dart index 6ae2d38e3..ad4d74b18 100644 --- a/mobile-app/lib/ui/views/learn/landing/landing_viewmodel.dart +++ b/mobile-app/lib/ui/views/learn/landing/landing_viewmodel.dart @@ -8,6 +8,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:freecodecamp/app/app.locator.dart'; import 'package:freecodecamp/app/app.router.dart'; +import 'package:freecodecamp/l10n/app_localizations.dart'; import 'package:freecodecamp/models/learn/challenge_model.dart'; import 'package:freecodecamp/models/learn/completed_challenge_model.dart'; import 'package:freecodecamp/models/learn/curriculum_model.dart'; @@ -20,6 +21,7 @@ import 'package:freecodecamp/service/firebase/remote_config_service.dart'; import 'package:freecodecamp/service/learn/daily_challenge_service.dart'; import 'package:freecodecamp/service/learn/learn_offline_service.dart'; import 'package:freecodecamp/service/learn/learn_service.dart'; +import 'package:freecodecamp/service/locale_service.dart'; import 'package:freecodecamp/ui/views/learn/landing/landing_view.dart'; import 'package:freecodecamp/ui/views/learn/utils/learn_globals.dart'; import 'package:freecodecamp/ui/widgets/setup_dialog_ui.dart'; @@ -39,6 +41,8 @@ class LearnLandingViewModel extends BaseViewModel { final SnackbarService _snack = locator(); SnackbarService get snack => _snack; + final LocaleService _localeService = locator(); + final _learnOfflineService = locator(); LearnOfflineService get learnOfflineService => _learnOfflineService; @@ -173,9 +177,10 @@ class LearnLandingViewModel extends BaseViewModel { } void disabledButtonSnack(bool disabledByManualOverride) { + final t = lookupAppLocalizations(_localeService.locale); final message = disabledByManualOverride - ? 'Temporarily unavailable, come back soon.' - : 'Not available use the web version'; + ? t.temporarily_unavailable + : t.not_available_web; snack.showSnackbar(title: message, message: ''); Future.delayed(const Duration(milliseconds: 2500), () { @@ -197,13 +202,14 @@ class LearnLandingViewModel extends BaseViewModel { Map superBlockStages = res.data['superblocks']; + final t = lookupAppLocalizations(_localeService.locale); Map stageOrder = { - 'core': 'Recommended curriculum (still in beta):', - 'english': 'Learn English for Developers:', - 'spanish': 'Learn Professional Spanish:', - 'chinese': 'Learn Professional Chinese:', - 'extra': 'Prepare for the developer interview job search:', - 'professional': 'Professional certifications:', + 'core': t.stage_core, + 'english': t.stage_english, + 'spanish': t.stage_spanish, + 'chinese': t.stage_chinese, + 'extra': t.stage_extra, + 'professional': t.stage_professional, }; List widgetOrder = []; diff --git a/mobile-app/lib/ui/views/learn/superblock/superblock_view.dart b/mobile-app/lib/ui/views/learn/superblock/superblock_view.dart index 0d623978e..70cf6baec 100644 --- a/mobile-app/lib/ui/views/learn/superblock/superblock_view.dart +++ b/mobile-app/lib/ui/views/learn/superblock/superblock_view.dart @@ -98,7 +98,7 @@ class SuperBlockView extends StatelessWidget { } }); - return blockTemplate(model, superBlock); + return blockTemplate(context, model, superBlock); } } @@ -119,12 +119,13 @@ class SuperBlockView extends StatelessWidget { } Widget blockTemplate( + BuildContext context, SuperBlockViewModel model, SuperBlock superBlock, ) { if (superBlock.blocks == null || superBlock.blocks!.isEmpty) { - return const Center( - child: Text('No blocks available right now.'), + return Center( + child: Text(context.t.no_blocks_available), ); } diff --git a/mobile-app/lib/ui/views/learn/utils/challenge_utils.dart b/mobile-app/lib/ui/views/learn/utils/challenge_utils.dart index 0c4eec4c6..22f4beca2 100644 --- a/mobile-app/lib/ui/views/learn/utils/challenge_utils.dart +++ b/mobile-app/lib/ui/views/learn/utils/challenge_utils.dart @@ -73,17 +73,23 @@ String formatChallengeDate(DateTime date) { return '${date.year.toString().padLeft(4, '0')}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; } -String parseSyntaxError(String errorMessage) { +String parseSyntaxError( + String errorMessage, { + String Function(int line)? lineMessage, + String? generalMessage, +}) { final syntaxErrorRegex = RegExp(r'SyntaxError:.*?\((\d+):(\d+)\)'); final match = syntaxErrorRegex.firstMatch(errorMessage); if (match != null) { final line = int.parse(match.group(1)!) - 1; - return 'There is a syntax error on line $line. Please check for missing brackets, quotes, or other syntax issues.'; + return lineMessage?.call(line) ?? + 'There is a syntax error on line $line. Please check for missing brackets, quotes, or other syntax issues.'; } if (errorMessage.contains('Babel transpilation failed')) { - return 'There is a syntax error in your code. Please check for missing brackets, quotes, or other syntax issues.'; + return generalMessage ?? + 'There is a syntax error in your code. Please check for missing brackets, quotes, or other syntax issues.'; } return errorMessage; diff --git a/mobile-app/lib/ui/views/learn/widgets/challenge_widgets/symbol_bar.dart b/mobile-app/lib/ui/views/learn/widgets/challenge_widgets/symbol_bar.dart index 0e2dd1ada..3674ecff9 100644 --- a/mobile-app/lib/ui/views/learn/widgets/challenge_widgets/symbol_bar.dart +++ b/mobile-app/lib/ui/views/learn/widgets/challenge_widgets/symbol_bar.dart @@ -1,65 +1,73 @@ import 'package:flutter/material.dart'; import 'package:flutter_scroll_shadow/flutter_scroll_shadow.dart'; +import 'package:freecodecamp/ui/viewmodels/symbol_bar_viewmodel.dart'; import 'package:freecodecamp/ui/views/learn/challenge/challenge_viewmodel.dart'; import 'package:phone_ide/phone_ide.dart'; +import 'package:stacked/stacked.dart'; +/// Symbol Bar Widget +/// +/// Displays a horizontal scrollable bar of quick-access symbols. +/// Supports both predefined and user-defined custom symbol sets. +/// +/// The symbol bar: +/// - Automatically adapts to the currently selected symbol set +/// - Persists user preferences via SymbolBarService +/// - Inserts symbols at the cursor position in the code editor class SymbolBar extends StatelessWidget { const SymbolBar({ super.key, required this.editor, - required this.model, + required this.challengeModel, }); + /// The code editor instance where symbols will be inserted final Editor editor; - final ChallengeViewModel model; - static const List symbols = [ - 'Tab', - '<', - '/', - '>', - '\\', - '\'', - '"', - '=', - '{', - '}' - ]; + /// The challenge view model that handles symbol insertion + final ChallengeViewModel challengeModel; @override Widget build(BuildContext context) { - return Container( - margin: const EdgeInsets.only(bottom: 8), - height: 50, - color: const Color(0xFF1b1b32), - child: ScrollShadow( - size: 12, - child: ListView.builder( - scrollDirection: Axis.horizontal, - controller: model.symbolBarScrollController, - padding: const EdgeInsets.symmetric(horizontal: 8), - itemCount: symbols.length, - itemBuilder: (context, index) { - return Padding( - padding: const EdgeInsets.symmetric( - vertical: 4, - horizontal: 1, - ), - child: TextButton( - onPressed: () { - model.insertSymbol(symbols[index], editor); - }, - style: TextButton.styleFrom( - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.zero), + return ViewModelBuilder.reactive( + viewModelBuilder: () => SymbolBarViewModel(), + onViewModelReady: (model) => model.init(), + builder: (context, model, child) { + return Container( + margin: const EdgeInsets.only(bottom: 8), + height: 50, + color: const Color(0xFF1b1b32), + child: ScrollShadow( + size: 12, + child: ListView.builder( + scrollDirection: Axis.horizontal, + controller: challengeModel.symbolBarScrollController, + padding: const EdgeInsets.symmetric(horizontal: 8), + itemCount: model.currentSymbols.length, + itemBuilder: (context, index) { + final symbol = model.currentSymbols[index]; + return Padding( + padding: const EdgeInsets.symmetric( + vertical: 4, + horizontal: 1, ), - ), - child: Text(symbols[index]), - ), - ); - }, - ), - ), + child: TextButton( + onPressed: () { + challengeModel.insertSymbol(symbol, editor); + }, + style: TextButton.styleFrom( + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.zero), + ), + ), + child: Text(symbol), + ), + ); + }, + ), + ), + ); + }, ); } } diff --git a/mobile-app/lib/ui/views/learn/widgets/daily_challenge_card.dart b/mobile-app/lib/ui/views/learn/widgets/daily_challenge_card.dart index d4f41d03c..cc25a1365 100644 --- a/mobile-app/lib/ui/views/learn/widgets/daily_challenge_card.dart +++ b/mobile-app/lib/ui/views/learn/widgets/daily_challenge_card.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:freecodecamp/app/app.locator.dart'; import 'package:freecodecamp/app/app.router.dart'; +import 'package:freecodecamp/extensions/i18n_extension.dart'; import 'package:freecodecamp/models/learn/daily_challenge_model.dart'; import 'package:freecodecamp/ui/theme/fcc_theme.dart'; import 'package:freecodecamp/ui/views/learn/utils/challenge_utils.dart'; @@ -94,7 +95,7 @@ class _DailyChallengeCardState extends State { children: [ // Card label Text( - "Today's challenge completed!", + context.t.daily_challenge_completed, style: TextStyle( fontSize: 16, fontWeight: FontWeight.w500, @@ -105,8 +106,8 @@ class _DailyChallengeCardState extends State { SizedBox(height: 14), // Countdown timer Semantics( - label: - 'Countdown timer. Next challenge in ${_formatDuration(_timeLeft)}', + label: context.t + .daily_challenge_countdown_label(_formatDuration(_timeLeft)), liveRegion: true, child: Container( padding: EdgeInsets.symmetric(horizontal: 12, vertical: 6), @@ -115,7 +116,7 @@ class _DailyChallengeCardState extends State { borderRadius: BorderRadius.circular(16), ), child: Text( - 'Next challenge in: ${_formatDuration(_timeLeft)}', + context.t.daily_challenge_next_in(_formatDuration(_timeLeft)), style: TextStyle( color: FccColors.gray90, fontWeight: FontWeight.w600, @@ -129,7 +130,7 @@ class _DailyChallengeCardState extends State { SizedBox( width: double.infinity, child: Tooltip( - message: 'View past daily challenges', + message: context.t.daily_challenge_view_past_tooltip, child: ElevatedButton.icon( style: ElevatedButton.styleFrom( backgroundColor: FccColors.yellow50, @@ -146,10 +147,10 @@ class _DailyChallengeCardState extends State { icon: Icon( Icons.history, size: 20, - semanticLabel: 'View past challenges', + semanticLabel: context.t.daily_challenge_view_past, ), label: Text( - 'View past challenges', + context.t.daily_challenge_view_past, style: TextStyle( fontSize: 18, fontWeight: FontWeight.bold, @@ -171,7 +172,7 @@ class _DailyChallengeCardState extends State { children: [ // Card label Text( - "Today's challenge", + context.t.daily_challenge_today, style: TextStyle( fontSize: 16, fontWeight: FontWeight.w500, @@ -193,7 +194,7 @@ class _DailyChallengeCardState extends State { SizedBox(height: 14), // Motivation text Text( - 'Do you have the skills to complete this challenge?', + context.t.daily_challenge_prompt, textAlign: TextAlign.center, style: TextStyle(fontSize: 18, color: FccColors.gray85), ), @@ -202,7 +203,7 @@ class _DailyChallengeCardState extends State { SizedBox( width: double.infinity, child: Tooltip( - message: 'Start the daily challenge now', + message: context.t.daily_challenge_start_tooltip, child: ElevatedButton.icon( style: ElevatedButton.styleFrom( backgroundColor: FccColors.yellow50, @@ -217,10 +218,10 @@ class _DailyChallengeCardState extends State { icon: Icon( Icons.arrow_forward_ios, size: 20, - semanticLabel: 'Go to challenge', + semanticLabel: context.t.daily_challenge_go_to_challenge, ), label: Text( - 'Start the challenge', + context.t.daily_challenge_start, style: TextStyle( fontSize: 18, fontWeight: FontWeight.bold, @@ -246,8 +247,8 @@ class _DailyChallengeCardState extends State { onTap: () {}, child: Semantics( label: widget.isCompleted - ? 'Daily challenge completed. View past challenges.' - : 'Daily challenge card', + ? context.t.daily_challenge_completed_semantics + : context.t.daily_challenge_card_semantics, container: true, child: Container( padding: const EdgeInsets.all(20), diff --git a/mobile-app/lib/ui/views/learn/widgets/example_editor.dart b/mobile-app/lib/ui/views/learn/widgets/example_editor.dart index 3ec946d2d..3be4e55a0 100644 --- a/mobile-app/lib/ui/views/learn/widgets/example_editor.dart +++ b/mobile-app/lib/ui/views/learn/widgets/example_editor.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_html/flutter_html.dart'; +import 'package:freecodecamp/extensions/i18n_extension.dart'; import 'package:freecodecamp/models/learn/challenge_model.dart'; import 'package:freecodecamp/ui/views/learn/widgets/challenge_card.dart'; import 'package:freecodecamp/ui/views/news/html_handler/html_handler.dart'; @@ -19,7 +20,7 @@ class ExampleEditor extends StatelessWidget { @override Widget build(BuildContext context) { return ChallengeCard( - title: 'Lesson', + title: context.t.challenge_card_lesson, child: Column( mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, diff --git a/mobile-app/lib/ui/views/learn/widgets/explanation_widget.dart b/mobile-app/lib/ui/views/learn/widgets/explanation_widget.dart index a3dedf232..72b2443d2 100644 --- a/mobile-app/lib/ui/views/learn/widgets/explanation_widget.dart +++ b/mobile-app/lib/ui/views/learn/widgets/explanation_widget.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:freecodecamp/extensions/i18n_extension.dart'; import 'package:freecodecamp/ui/views/news/html_handler/html_handler.dart'; class Explanation extends StatelessWidget { @@ -16,7 +17,7 @@ class Explanation extends StatelessWidget { return ExpansionTile( backgroundColor: Colors.transparent, collapsedBackgroundColor: Colors.transparent, - title: const Text('Tap to expand'), + title: Text(context.t.tap_to_expand), shape: const RoundedRectangleBorder( side: BorderSide.none, borderRadius: BorderRadius.zero, diff --git a/mobile-app/lib/ui/views/learn/widgets/hint/hint_widget_view.dart b/mobile-app/lib/ui/views/learn/widgets/hint/hint_widget_view.dart index c41295d80..577294903 100644 --- a/mobile-app/lib/ui/views/learn/widgets/hint/hint_widget_view.dart +++ b/mobile-app/lib/ui/views/learn/widgets/hint/hint_widget_view.dart @@ -24,15 +24,16 @@ Future genForumLink( final userDeviceInfo = await getDeviceInfo(context); final titleText = '$blockTitle - ${currChallenge.title}'; + final t = context.t; final String endingText = - '**Your mobile information:**\n```txt\n$userDeviceInfo\n```\n\n**Challenge:** $titleText\n\n**Link to the challenge:**\nhttps://www.freecodecamp.org/learn/${currChallenge.superBlock}/${currChallenge.block}/${currChallenge.dashedName}'; + '**${t.forum_mobile_info}**\n```txt\n$userDeviceInfo\n```\n\n**${t.forum_challenge}** $titleText\n\n**${t.forum_challenge_link}**\nhttps://www.freecodecamp.org/learn/${currChallenge.superBlock}/${currChallenge.block}/${currChallenge.dashedName}'; final String userCode = await filesToMarkdown(currChallenge, editorText); final String textMessage = - "**Tell us what's happening:**\nDescribe your issue in detail here.\n\n**Your code so far**$userCode\n\n$endingText"; + '**${t.forum_tell_us_heading}**\n${t.forum_describe_issue}\n\n**${t.forum_code_so_far}**$userCode\n\n$endingText'; final String altTextMessage = - "**Tell us what's happening:**\n\n\n\n**Your code so far**\n\nWARNING\n\nThe challenge seed code and/or your solution exceeded the maximum length we can port over from the challenge.\n\nYou will need to take an additional step here so the code you wrote presents in an easy to read format.\n\nPlease copy/paste all the editor code showing in the challenge from where you just linked.\n\n```\nReplace these two sentences with your copied code.\nPlease leave the ``` line above and the ``` line below,\nbecause they allow your code to properly format in the post.\n\n```\n$endingText"; + '**${t.forum_tell_us_heading}**\n\n\n\n**${t.forum_code_so_far}**\n\n${t.forum_warning}\n\n${t.forum_code_too_long}\n\n${t.forum_additional_step}\n\n${t.forum_copy_editor_code}\n\n```\n${t.forum_replace_code}\n\n```\n$endingText'; String studentCode = Uri.encodeComponent(textMessage); String altStudentCode = Uri.encodeComponent(altTextMessage); diff --git a/mobile-app/lib/ui/views/learn/widgets/quiz_audio_player.dart b/mobile-app/lib/ui/views/learn/widgets/quiz_audio_player.dart index a185975bf..6595b4d20 100644 --- a/mobile-app/lib/ui/views/learn/widgets/quiz_audio_player.dart +++ b/mobile-app/lib/ui/views/learn/widgets/quiz_audio_player.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:audio_service/audio_service.dart'; import 'package:flutter/material.dart'; import 'package:freecodecamp/app/app.locator.dart'; +import 'package:freecodecamp/extensions/i18n_extension.dart'; import 'package:freecodecamp/models/learn/challenge_model.dart'; import 'package:freecodecamp/service/audio/audio_service.dart'; import 'package:freecodecamp/ui/theme/fcc_theme.dart'; @@ -76,10 +77,10 @@ class _QuizAudioPlayerState extends State { } if (_hasError) { - return const Center( + return Center( child: Padding( - padding: EdgeInsets.all(16.0), - child: Text('Error loading audio'), + padding: const EdgeInsets.all(16.0), + child: Text(context.t.audio_load_error), ), ); } @@ -173,7 +174,7 @@ class _TranscriptWidgetState extends State<_TranscriptWidget> { @override Widget build(BuildContext context) { return ChallengeCard( - title: 'Transcript', + title: context.t.challenge_card_transcript, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -186,9 +187,9 @@ class _TranscriptWidgetState extends State<_TranscriptWidget> { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - const Text( - 'Show transcript', - style: TextStyle( + Text( + context.t.show_transcript, + style: const TextStyle( fontSize: 16, fontWeight: FontWeight.bold, ), diff --git a/mobile-app/lib/ui/views/learn/widgets/quiz_widget.dart b/mobile-app/lib/ui/views/learn/widgets/quiz_widget.dart index f7f5c5235..e2c6cbf19 100644 --- a/mobile-app/lib/ui/views/learn/widgets/quiz_widget.dart +++ b/mobile-app/lib/ui/views/learn/widgets/quiz_widget.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_html/flutter_html.dart'; +import 'package:freecodecamp/extensions/i18n_extension.dart'; import 'package:freecodecamp/models/learn/challenge_model.dart'; import 'package:freecodecamp/ui/theme/fcc_theme.dart'; import 'package:freecodecamp/ui/views/learn/widgets/challenge_card.dart'; @@ -117,7 +118,9 @@ class _QuizWidgetState extends State { final question = widget.questions[questionIndex]; final selectedAnswer = question.selectedAnswer; return ChallengeCard( - title: questionNumber != null ? 'Question $questionNumber' : 'Question', + title: questionNumber != null + ? context.t.quiz_question_number(questionNumber) + : context.t.quiz_question, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -217,7 +220,9 @@ class _QuizWidgetState extends State { Padding( padding: EdgeInsets.only(left: 12, bottom: feedback == null ? 24 : 0), child: Text( - isCorrect && isCorrect == true ? 'Correct!' : 'Incorrect!', + isCorrect && isCorrect == true + ? context.t.quiz_correct + : context.t.quiz_incorrect, style: TextStyle( color: isCorrect && isCorrect == true ? FccColors.green40 diff --git a/mobile-app/lib/ui/views/learn/widgets/transcript_widget.dart b/mobile-app/lib/ui/views/learn/widgets/transcript_widget.dart index 2fccde628..f86ab9b3d 100644 --- a/mobile-app/lib/ui/views/learn/widgets/transcript_widget.dart +++ b/mobile-app/lib/ui/views/learn/widgets/transcript_widget.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:freecodecamp/extensions/i18n_extension.dart'; import 'package:freecodecamp/ui/views/news/html_handler/html_handler.dart'; class Transcript extends StatelessWidget { @@ -19,7 +20,7 @@ class Transcript extends StatelessWidget { return ExpansionTile( backgroundColor: Colors.transparent, collapsedBackgroundColor: Colors.transparent, - title: const Text('Tap to expand'), + title: Text(context.t.tap_to_expand), shape: const RoundedRectangleBorder( side: BorderSide.none, borderRadius: BorderRadius.zero, diff --git a/mobile-app/lib/ui/views/login/native_login_view.dart b/mobile-app/lib/ui/views/login/native_login_view.dart index 492439816..737e19ff3 100644 --- a/mobile-app/lib/ui/views/login/native_login_view.dart +++ b/mobile-app/lib/ui/views/login/native_login_view.dart @@ -215,7 +215,7 @@ class NativeLoginView extends StatelessWidget { } : null, child: Text( - 'Submit and sign in to freeCodeCamp', + context.t.email_submit_code, style: TextStyle( fontSize: 20, ), @@ -297,46 +297,7 @@ class NativeLoginView extends StatelessWidget { style: TextStyle( color: Colors.white.withValues(alpha: 0.87), ), - children: [ - TextSpan( - text: - "By continuing, you indicate that you have read and agree to freeCodeCamp.org's ", - ), - TextSpan( - text: 'Terms of Service', - style: const TextStyle( - decoration: TextDecoration.underline, - ), - recognizer: TapGestureRecognizer() - ..onTap = () { - launchUrl( - Uri.parse( - 'https://www.freecodecamp.org/news/terms-of-service', - ), - ); - }, - ), - TextSpan( - text: ' and ', - ), - TextSpan( - text: 'Privacy Policy', - style: const TextStyle( - decoration: TextDecoration.underline, - ), - recognizer: TapGestureRecognizer() - ..onTap = () { - launchUrl( - Uri.parse( - 'https://www.freecodecamp.org/news/privacy-policy', - ), - ); - }, - ), - TextSpan( - text: '.', - ), - ], + children: _buildLoginTermsSpans(context), ), ), )), @@ -349,4 +310,73 @@ class NativeLoginView extends StatelessWidget { ), ); } + + List _buildLoginTermsSpans(BuildContext context) { + final linkStyle = const TextStyle( + decoration: TextDecoration.underline, + ); + final termsOfService = context.t.terms_of_service; + final privacyPolicy = context.t.privacy_policy; + final message = context.t.login_terms(termsOfService, privacyPolicy); + final links = [ + _LoginTermsLink( + label: termsOfService, + url: 'https://www.freecodecamp.org/news/terms-of-service', + ), + _LoginTermsLink( + label: privacyPolicy, + url: 'https://www.freecodecamp.org/news/privacy-policy', + ), + ]; + + final orderedLinks = links + .map((link) => MapEntry(message.indexOf(link.label), link)) + .where((entry) => entry.key >= 0) + .toList() + ..sort((a, b) => a.key.compareTo(b.key)); + + if (orderedLinks.length != links.length) { + return [TextSpan(text: message)]; + } + + final spans = []; + var cursor = 0; + + for (final entry in orderedLinks) { + final linkStart = entry.key; + final link = entry.value; + + if (linkStart > cursor) { + spans.add(TextSpan(text: message.substring(cursor, linkStart))); + } + + spans.add( + TextSpan( + text: link.label, + style: linkStyle, + recognizer: TapGestureRecognizer() + ..onTap = () { + launchUrl(Uri.parse(link.url)); + }, + ), + ); + cursor = linkStart + link.label.length; + } + + if (cursor < message.length) { + spans.add(TextSpan(text: message.substring(cursor))); + } + + return spans; + } +} + +class _LoginTermsLink { + const _LoginTermsLink({ + required this.label, + required this.url, + }); + + final String label; + final String url; } diff --git a/mobile-app/lib/ui/views/news/html_handler/html_handler.dart b/mobile-app/lib/ui/views/news/html_handler/html_handler.dart index fc9ed9abc..28f508a5b 100644 --- a/mobile-app/lib/ui/views/news/html_handler/html_handler.dart +++ b/mobile-app/lib/ui/views/news/html_handler/html_handler.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_html/flutter_html.dart'; import 'package:flutter_html_table/flutter_html_table.dart'; +import 'package:freecodecamp/extensions/i18n_extension.dart'; import 'package:freecodecamp/ui/theme/fcc_theme.dart'; import 'package:freecodecamp/ui/views/news/news-image-viewer/news_image_view.dart'; import 'package:html/dom.dart' as dom; @@ -256,22 +257,9 @@ class HTMLParser { Clipboard.setData(ClipboardData(text: parsed)); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text.rich( - TextSpan( - children: [ - TextSpan( - text: parsed, - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 20, - ), - ), - const TextSpan( - text: ' copied to clipboard!', - style: TextStyle(fontSize: 20), - ), - ], - ), + content: Text( + context.t.copied_to_clipboard(parsed), + style: const TextStyle(fontSize: 20), ), duration: const Duration(seconds: 1), ), diff --git a/mobile-app/lib/ui/views/news/news-bookmark/news_bookmark_feed_view.dart b/mobile-app/lib/ui/views/news/news-bookmark/news_bookmark_feed_view.dart index 9f411708d..bc89cdf76 100644 --- a/mobile-app/lib/ui/views/news/news-bookmark/news_bookmark_feed_view.dart +++ b/mobile-app/lib/ui/views/news/news-bookmark/news_bookmark_feed_view.dart @@ -54,7 +54,7 @@ ListView populateListViewModel(NewsBookmarkViewModel model) { trailing: const Icon(Icons.arrow_forward_ios_sharp), subtitle: Padding( padding: const EdgeInsets.only(top: 8.0), - child: Text('Written by: ${tutorial.authorName}'), + child: Text(context.t.tutorial_written_by_colon(tutorial.authorName)), ), onTap: () { model.routeToBookmarkedTutorial(tutorial); diff --git a/mobile-app/lib/ui/views/news/news-image-viewer/news_image_view.dart b/mobile-app/lib/ui/views/news/news-image-viewer/news_image_view.dart index 444e04fa0..8337d18a9 100644 --- a/mobile-app/lib/ui/views/news/news-image-viewer/news_image_view.dart +++ b/mobile-app/lib/ui/views/news/news-image-viewer/news_image_view.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:flutter/material.dart'; +import 'package:freecodecamp/extensions/i18n_extension.dart'; import 'package:freecodecamp/ui/views/news/news-image-viewer/news_image_viewmodel.dart'; import 'package:photo_view/photo_view.dart'; import 'package:stacked/stacked.dart'; @@ -24,7 +25,7 @@ class NewsImageView extends StatelessWidget { backgroundColor: Colors.transparent, shadowColor: Colors.transparent, leading: Tooltip( - message: 'Back', + message: context.t.back, child: InkWell( onTap: () { Navigator.pop(context); diff --git a/mobile-app/lib/ui/views/news/news-tutorial/news_tutorial_viewmodel.dart b/mobile-app/lib/ui/views/news/news-tutorial/news_tutorial_viewmodel.dart index bffa2a199..06f33f75c 100644 --- a/mobile-app/lib/ui/views/news/news-tutorial/news_tutorial_viewmodel.dart +++ b/mobile-app/lib/ui/views/news/news-tutorial/news_tutorial_viewmodel.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:freecodecamp/app/app.locator.dart'; +import 'package:freecodecamp/extensions/i18n_extension.dart'; import 'package:freecodecamp/models/news/tutorial_model.dart'; import 'package:freecodecamp/service/news/api_service.dart'; import 'package:freecodecamp/ui/views/news/html_handler/html_handler.dart'; @@ -124,7 +125,7 @@ class NewsTutorialViewModel extends BaseViewModel { ); } - List initLazyLoading(html, context, tutorial) { + List initLazyLoading(html, BuildContext context, tutorial) { HTMLParser parser = HTMLParser(context: context); List elements = parser.parse(html); @@ -140,7 +141,7 @@ class NewsTutorialViewModel extends BaseViewModel { backgroundColor: Colors.transparent, shadowColor: Colors.transparent, leading: Tooltip( - message: 'Back', + message: context.t.back, child: InkWell( onTap: () { Navigator.pop(context); diff --git a/mobile-app/lib/ui/views/podcast/episode/episode_view.dart b/mobile-app/lib/ui/views/podcast/episode/episode_view.dart index 25a573421..63fd4b42b 100644 --- a/mobile-app/lib/ui/views/podcast/episode/episode_view.dart +++ b/mobile-app/lib/ui/views/podcast/episode/episode_view.dart @@ -1,5 +1,6 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import 'package:freecodecamp/extensions/i18n_extension.dart'; import 'package:freecodecamp/models/podcasts/episodes_model.dart'; import 'package:freecodecamp/models/podcasts/podcasts_model.dart'; import 'package:freecodecamp/ui/views/news/html_handler/html_handler.dart'; @@ -177,11 +178,11 @@ class EpisodeView extends StatelessWidget { }, icon: (() { if (model.isDownloaded) { - return const Icon( + return Icon( Icons.download_done, color: Colors.white, size: 30, - semanticLabel: 'Download complete', + semanticLabel: context.t.download_complete, ); } else if (model.isDownloading && model.downloadService.downloadId == episode.id) { @@ -191,11 +192,11 @@ class EpisodeView extends StatelessWidget { final progress = snapshot.data; double? val = double.tryParse(progress ?? '0'); if (progress == '100') { - return const Icon( + return Icon( Icons.download_done, color: Colors.white, size: 30, - semanticLabel: 'Download complete', + semanticLabel: context.t.download_complete, ); } else if (progress != null && progress != '') { return Stack( @@ -222,11 +223,11 @@ class EpisodeView extends StatelessWidget { }, ); } else { - return const Icon( + return Icon( Icons.arrow_circle_down_outlined, color: Colors.white, size: 30, - semanticLabel: 'Download episode', + semanticLabel: context.t.download_episode, ); } })(), diff --git a/mobile-app/lib/ui/views/podcast/widgets/podcast_title_widget.dart b/mobile-app/lib/ui/views/podcast/widgets/podcast_title_widget.dart index 18b6c1b6e..f810ea02f 100644 --- a/mobile-app/lib/ui/views/podcast/widgets/podcast_title_widget.dart +++ b/mobile-app/lib/ui/views/podcast/widgets/podcast_title_widget.dart @@ -4,6 +4,7 @@ import 'package:audio_service/audio_service.dart'; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:freecodecamp/app/app.locator.dart'; +import 'package:freecodecamp/extensions/i18n_extension.dart'; import 'package:freecodecamp/models/podcasts/episodes_model.dart'; import 'package:freecodecamp/models/podcasts/podcasts_model.dart'; import 'package:freecodecamp/service/audio/audio_service.dart'; @@ -269,7 +270,9 @@ class PodcastTileState extends State { widget.playing ? Icons.pause : Icons.play_arrow, color: FccColors.gray00, size: 33, - semanticLabel: widget.playing ? 'Pause episode' : 'Play episode', + semanticLabel: widget.playing + ? context.t.podcast_pause_episode + : context.t.podcast_play_episode, ), onPressed: () { playBtnClick(); @@ -319,11 +322,11 @@ class PodcastTileState extends State { builder: (context, snapshot) { final progress = snapshot.data; if (progress == '100') { - return const Icon( + return Icon( Icons.download_done, color: FccColors.gray10, size: 20, - semanticLabel: 'Download complete', + semanticLabel: context.t.download_complete, ); } else if (progress != null && progress != '') { return Stack(alignment: Alignment.center, children: [ @@ -347,17 +350,17 @@ class PodcastTileState extends State { } }) : widget.downloaded - ? const Icon( + ? Icon( Icons.download_done, color: FccColors.gray10, size: 20, - semanticLabel: 'Download complete', + semanticLabel: context.t.download_complete, ) - : const Icon( + : Icon( Icons.download, color: FccColors.gray10, size: 20, - semanticLabel: 'Download episode', + semanticLabel: context.t.download_episode, ), ); } diff --git a/mobile-app/lib/ui/views/profile/profile_view.dart b/mobile-app/lib/ui/views/profile/profile_view.dart index 17883f7ad..22c919abc 100644 --- a/mobile-app/lib/ui/views/profile/profile_view.dart +++ b/mobile-app/lib/ui/views/profile/profile_view.dart @@ -91,11 +91,11 @@ class ProfileView extends StatelessWidget { child: Column( children: [ _buildHeader(user), - _buildAboutCard(user), + _buildAboutCard(context, user), _buildInfoSection(context, user, streak), _buildHeatmap(context, user, streak), _buildPortfolio(user), - _buildCertifications(user), + _buildCertifications(context, user), ], ), ), @@ -153,26 +153,33 @@ class ProfileView extends StatelessWidget { ) { final infoRows = [ if (user.location != null && user.location!.trim().isNotEmpty) - _infoRow( - Icons.location_on, 'Location', user.location!, FccColors.blue50), + _infoRow(Icons.location_on, context.t.profile_location, user.location!, + FccColors.blue50), if (user.isDonating) - _infoRow(Icons.favorite, 'Supporter', context.t.profile_supporter, - FccColors.yellow45), + _infoRow(Icons.favorite, context.t.profile_supporter, + context.t.profile_supporter, FccColors.yellow45), if (user.twitter != null && user.twitter!.trim().isNotEmpty) - _infoRow(Icons.alternate_email, 'X', user.twitter!, FccColors.blue50), + _infoRow(Icons.alternate_email, context.t.profile_x, user.twitter!, + FccColors.blue50), if (user.githubProfile != null && user.githubProfile!.trim().isNotEmpty) - _infoRow(Icons.code, 'GitHub', user.githubProfile!, FccColors.blue50), + _infoRow(Icons.code, context.t.profile_github, user.githubProfile!, + FccColors.blue50), if (user.linkedin != null && user.linkedin!.trim().isNotEmpty) - _infoRow(Icons.business, 'LinkedIn', user.linkedin!, FccColors.blue50), + _infoRow(Icons.business, context.t.profile_linkedin, user.linkedin!, + FccColors.blue50), if (user.website != null && user.website!.trim().isNotEmpty) - _infoRow(Icons.language, 'Website', user.website!, FccColors.blue50), - _infoRow(Icons.calendar_month, 'Joined', + _infoRow(Icons.language, context.t.profile_website, user.website!, + FccColors.blue50), + _infoRow(Icons.calendar_month, context.t.profile_joined, Jiffy.parseFromDateTime(user.joinDate).yMMMM, FccColors.gray15), - _infoRow(Icons.local_fire_department_sharp, 'Points', - user.points.toString(), FccColors.red30), - _infoRow(Icons.emoji_events, 'Longest Streak', + _infoRow( + Icons.local_fire_department_sharp, + context.t.profile_points_label, + user.points.toString(), + FccColors.red30), + _infoRow(Icons.emoji_events, context.t.profile_longest_streak_label, (streak['longest'] ?? 0).toString(), FccColors.orange30), - _infoRow(Icons.bolt, 'Current Streak', + _infoRow(Icons.bolt, context.t.profile_current_streak_label, (streak['current'] ?? 0).toString(), FccColors.orange30), ]; return Container( @@ -293,7 +300,7 @@ class ProfileView extends StatelessWidget { ); } - Widget _buildAboutCard(FccUserModel user) { + Widget _buildAboutCard(BuildContext context, FccUserModel user) { if (user.about == null || user.about!.trim().isEmpty) { return const SizedBox.shrink(); } @@ -312,9 +319,9 @@ class ProfileView extends StatelessWidget { children: [ Icon(Icons.chat_bubble, color: FccColors.gray00, size: 22), const SizedBox(width: 10), - const Text( - 'About', - style: TextStyle( + Text( + context.t.profile_about, + style: const TextStyle( color: FccColors.gray00, fontWeight: FontWeight.bold, fontSize: 16, @@ -520,7 +527,7 @@ class ProfileView extends StatelessWidget { ); } - Widget _buildCertifications(FccUserModel user) { + Widget _buildCertifications(BuildContext context, FccUserModel user) { final hasModernCert = user.isRespWebDesignCert || user.is2018DataVisCert || user.isFrontEndLibsCert || @@ -542,67 +549,67 @@ class ProfileView extends StatelessWidget { final currentCerts = [ { 'show': user.isRespWebDesignCert, - 'title': 'Responsive Web Design Certification', + 'title': context.t.cert_responsive_web_design, 'certSlug': 'responsive-web-design', }, { 'show': user.isJsAlgoDataStructCert, - 'title': 'JavaScript Algorithms and Data Structures Certification', + 'title': context.t.cert_javascript_algorithms, 'certSlug': 'javascript-algorithms-and-data-structures' }, { 'show': user.isFrontEndLibsCert, - 'title': 'Front End Development Libraries Certification', + 'title': context.t.cert_front_end_libraries, 'certSlug': 'front-end-development-libraries', }, { 'show': user.is2018DataVisCert, - 'title': 'Data Visualization Certification', + 'title': context.t.cert_data_visualization, 'certSlug': 'data-visualization', }, { 'show': user.isApisMicroservicesCert, - 'title': 'Back End Development and APIs Certification', + 'title': context.t.cert_back_end_development_apis, 'certSlug': 'back-end-development-and-apis', }, { 'show': user.isQaCertV7, - 'title': ' Quality Assurance Certification', + 'title': context.t.cert_quality_assurance, 'certSlug': 'quality-assurance-v7', }, { 'show': user.isInfosecCertV7, - 'title': 'Information Security Certification', + 'title': context.t.cert_information_security, 'certSlug': 'information-security-v7', }, { 'show': user.isSciCompPyCertV7, - 'title': 'Scientific Computing with Python Certification', + 'title': context.t.cert_scientific_computing_python, 'certSlug': 'scientific-computing-with-python-v7', }, { 'show': user.isDataAnalysisPyCertV7, - 'title': 'Data Analysis with Python Certification', + 'title': context.t.cert_data_analysis_python, 'certSlug': 'data-analysis-with-python-v7', }, { 'show': user.isMachineLearningPyCertV7, - 'title': 'Machine Learning with Python Certification', + 'title': context.t.cert_machine_learning_python, 'certSlug': 'machine-learning-with-python-v7', }, { 'show': user.isRelationalDatabaseCertV8, - 'title': 'Relational Database Certification', + 'title': context.t.cert_relational_database, 'certSlug': 'relational-database-v8', }, { 'show': user.isCollegeAlgebraPyCertV8, - 'title': 'College Algebra with Python', + 'title': context.t.cert_college_algebra_python, 'certSlug': 'college-algebra-with-python-v8', }, { 'show': user.isFoundationalCSharpCertV8, - 'title': 'Foundational C# with Microsoft', + 'title': context.t.cert_foundational_csharp, 'certSlug': 'foundational-c-sharp-with-microsoft', } ]; @@ -610,27 +617,27 @@ class ProfileView extends StatelessWidget { final legacyCerts = [ { 'show': user.isFrontEndCert, - 'title': 'Front End Certification', + 'title': context.t.cert_front_end, 'certSlug': 'legacy-front-end' }, { 'show': user.isBackEndCert, - 'title': 'Back End Certification', + 'title': context.t.cert_back_end, 'certSlug': 'legacy-back-end' }, { 'show': user.isDataVisCert, - 'title': 'Data Visualization Certification', + 'title': context.t.cert_data_visualization, 'certSlug': 'legacy-data-visualization' }, { 'show': user.isInfosecQaCert, - 'title': 'Information Security and Quality Assurance Certification', + 'title': context.t.cert_information_security_quality_assurance, 'certSlug': 'information-security-and-quality-assurance' }, { 'show': user.isFullStackCert, - 'title': 'Full Stack Certification', + 'title': context.t.cert_full_stack, 'certSlug': 'full-stack' } ]; @@ -648,7 +655,7 @@ class ProfileView extends StatelessWidget { Padding( padding: EdgeInsets.only(top: 8, bottom: 8), child: Text( - 'freeCodeCamp Certifications', + context.t.profile_certifications, textAlign: TextAlign.center, style: TextStyle( fontSize: 20, @@ -659,7 +666,7 @@ class ProfileView extends StatelessWidget { ), if (!hasModernCert && !hasLegacyCert) Text( - 'You have not yet earned any certifications.', + context.t.profile_no_certifications, style: TextStyle( fontSize: 16, color: Colors.white, diff --git a/mobile-app/lib/ui/views/settings/settings_view.dart b/mobile-app/lib/ui/views/settings/settings_view.dart index 2a28d073f..7d59adab5 100644 --- a/mobile-app/lib/ui/views/settings/settings_view.dart +++ b/mobile-app/lib/ui/views/settings/settings_view.dart @@ -3,6 +3,7 @@ import 'package:freecodecamp/extensions/i18n_extension.dart'; import 'package:freecodecamp/service/authentication/authentication_service.dart'; import 'package:freecodecamp/ui/views/settings/delete-account/delete_account_view.dart'; import 'package:freecodecamp/ui/views/settings/settings_viewmodel.dart'; +import 'package:freecodecamp/ui/views/settings/widgets/symbol_settings_widget.dart'; import 'package:freecodecamp/ui/widgets/drawer_widget/drawer_widget_view.dart'; import 'package:stacked/stacked.dart'; @@ -51,6 +52,21 @@ class SettingsView extends StatelessWidget { onTap: () => model.resetCache(context), ), buildDivider(), + ListTile( + leading: const Icon(Icons.functions), + title: const Text('Symbol Bar Settings'), + subtitle: const Text( + 'Customize quick-access symbols', + ), + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const SymbolSettingsWidget(), + settings: const RouteSettings(name: '/symbol-settings'), + ), + ), + ), + buildDivider(), ListTile( leading: const Icon(Icons.privacy_tip), title: Text( diff --git a/mobile-app/lib/ui/views/settings/widgets/symbol_settings_widget.dart b/mobile-app/lib/ui/views/settings/widgets/symbol_settings_widget.dart new file mode 100644 index 000000000..c0da4f555 --- /dev/null +++ b/mobile-app/lib/ui/views/settings/widgets/symbol_settings_widget.dart @@ -0,0 +1,474 @@ +/// Symbol Settings Widget +/// +/// Provides a comprehensive UI for managing symbol sets: +/// - View and switch between predefined symbol sets +/// - Create new custom symbol sets +/// - Edit and delete custom symbol sets +/// - Reset to defaults +/// +/// This widget is embedded in the Settings view to give users +/// full control over their symbol bar customization. +/// /// Symbol Settings Widget + +library; + +import 'package:flutter/material.dart'; +import 'package:freecodecamp/models/symbol_set_model.dart'; +import 'package:freecodecamp/ui/viewmodels/symbol_bar_viewmodel.dart'; +import 'package:stacked/stacked.dart'; + +class SymbolSettingsWidget extends StatelessWidget { + const SymbolSettingsWidget({super.key}); + + @override + Widget build(BuildContext context) { + return ViewModelBuilder.reactive( + viewModelBuilder: () => SymbolBarViewModel(), + onViewModelReady: (model) => model.init(), + builder: (context, model, child) { + return Scaffold( + appBar: AppBar( + title: const Text('Symbol Bar Settings'), + ), + body: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Predefined Sets Section + _buildSectionHeader(context, 'Predefined Symbol Sets'), + ...model.predefinedSets + .map((set) => _buildPredefinedSetTile(context, model, set)), + const Divider(), + + // Custom Sets Section + _buildSectionHeader(context, 'Custom Symbol Sets'), + if (model.customSymbolSets.isEmpty) + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 16, + ), + child: Text( + 'No custom symbol sets created yet', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Colors.grey, + ), + ), + ) + else + ...model.customSymbolSets + .map((set) => _buildCustomSetTile(context, model, set)), + + // Create Custom Set Button + Padding( + padding: const EdgeInsets.all(16), + child: SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: () => + _showCreateCustomSetDialog(context, model), + icon: const Icon(Icons.add), + label: const Text('Create Custom Set'), + ), + ), + ), + + const Divider(), + + // Reset to Defaults Button + if (model.customSymbolSets.isNotEmpty) + Padding( + padding: const EdgeInsets.all(16), + child: SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () => + _showResetConfirmDialog(context, model), + icon: const Icon(Icons.refresh), + label: const Text('Reset to Defaults'), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.red, + ), + ), + ), + ), + ], + ), + ), + ); + }, + ); + } + + /// Build a predefined set ListTile + Widget _buildPredefinedSetTile( + BuildContext context, + SymbolBarViewModel model, + PredefinedSymbolSet set, + ) { + final isActive = model.activeSet is PredefinedSymbolSet && + (model.activeSet as PredefinedSymbolSet).type == set.type; + + return ListTile( + leading: Icon( + isActive ? Icons.check_circle : Icons.radio_button_unchecked, + color: isActive ? Colors.green : Colors.grey, + ), + title: Text(set.type.displayName), + subtitle: Text( + '${set.symbols.length} symbols', + style: Theme.of(context).textTheme.bodySmall, + ), + trailing: _buildSymbolPreview(set.symbols), + onTap: () => model.switchToPredefinedSet(set.type), + ); + } + + /// Build a custom set ListTile with edit/delete options + Widget _buildCustomSetTile( + BuildContext context, + SymbolBarViewModel model, + CustomSymbolSet set, + ) { + final isActive = model.activeSet is CustomSymbolSet && + (model.activeSet as CustomSymbolSet).name == set.name; + + return ListTile( + leading: Icon( + isActive ? Icons.check_circle : Icons.radio_button_unchecked, + color: isActive ? Colors.green : Colors.grey, + ), + title: Text(set.name), + subtitle: Text( + '${set.symbols.length} symbols', + style: Theme.of(context).textTheme.bodySmall, + ), + trailing: SizedBox( + width: 120, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + IconButton( + icon: const Icon(Icons.edit, size: 20), + tooltip: 'Edit', + onPressed: () => _showEditCustomSetDialog(context, model, set), + ), + IconButton( + icon: const Icon(Icons.delete, size: 20, color: Colors.red), + tooltip: 'Delete', + onPressed: () => _showDeleteConfirmDialog(context, model, set), + ), + ], + ), + ), + onTap: () => model.switchToCustomSet(set.name), + ); + } + + /// Build a small preview of symbols + Widget _buildSymbolPreview(List symbols) { + final preview = symbols.take(3).join(' '); + return Tooltip( + message: symbols.join(', '), + child: Text( + preview + (symbols.length > 3 ? '...' : ''), + style: const TextStyle( + fontSize: 11, + fontFamily: 'monospace', + ), + ), + ); + } + + /// Build a section header + Widget _buildSectionHeader(BuildContext context, String title) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Text( + title, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ); + } + + // ==================== DIALOGS ==================== + + /// Show dialog to create a new custom symbol set + void _showCreateCustomSetDialog( + BuildContext context, + SymbolBarViewModel model, + ) { + final nameController = TextEditingController(); + final symbolsController = TextEditingController(); + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Create Custom Symbol Set'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: nameController, + decoration: const InputDecoration( + labelText: 'Set Name', + hintText: 'e.g., My Custom Symbols', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 16), + TextField( + controller: symbolsController, + maxLines: 4, + decoration: const InputDecoration( + labelText: 'Symbols (comma-separated)', + hintText: 'e.g., (, ), {, }, [, ]', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 8), + Text( + 'Separate symbols with commas. Maximum 50 symbols.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.grey, + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () { + final name = nameController.text.trim(); + final symbolsText = symbolsController.text.trim(); + + if (name.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please enter a set name')), + ); + return; + } + + final symbols = symbolsText + .split(',') + .map((s) => s.trim()) + .where((s) => s.isNotEmpty) + .toList(); + + if (symbols.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Please enter at least one symbol')), + ); + return; + } + + if (symbols.length > 50) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Maximum 50 symbols allowed')), + ); + return; + } + + try { + model.createCustomSet(name: name, symbols: symbols); + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Custom set "$name" created')), + ); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: $e')), + ); + } + }, + child: const Text('Create'), + ), + ], + ), + ); + } + + /// Show dialog to edit a custom symbol set + void _showEditCustomSetDialog( + BuildContext context, + SymbolBarViewModel model, + CustomSymbolSet set, + ) { + final nameController = TextEditingController(text: set.name); + final symbolsController = + TextEditingController(text: set.symbols.join(', ')); + + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text('Edit "${set.name}"'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: nameController, + decoration: const InputDecoration( + labelText: 'Set Name', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 16), + TextField( + controller: symbolsController, + maxLines: 4, + decoration: const InputDecoration( + labelText: 'Symbols (comma-separated)', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 8), + Text( + 'Separate symbols with commas. Maximum 50 symbols.', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.grey, + ), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () { + final newName = nameController.text.trim(); + final symbolsText = symbolsController.text.trim(); + + if (newName.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Please enter a set name')), + ); + return; + } + + final symbols = symbolsText + .split(',') + .map((s) => s.trim()) + .where((s) => s.isNotEmpty) + .toList(); + + if (symbols.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Please enter at least one symbol')), + ); + return; + } + + if (symbols.length > 50) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Maximum 50 symbols allowed')), + ); + return; + } + + try { + model.updateCustomSet( + currentName: set.name, + newName: newName, + newSymbols: symbols, + ); + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Custom set updated')), + ); + } catch (e) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Error: $e')), + ); + } + }, + child: const Text('Save'), + ), + ], + ), + ); + } + + /// Show confirmation dialog to delete a custom set + void _showDeleteConfirmDialog( + BuildContext context, + SymbolBarViewModel model, + CustomSymbolSet set, + ) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Delete Custom Set?'), + content: Text( + 'Are you sure you want to delete "${set.name}"? This action cannot be undone.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () { + model.deleteCustomSet(set.name); + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Custom set "${set.name}" deleted')), + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + ), + child: const Text('Delete'), + ), + ], + ), + ); + } + + /// Show confirmation dialog to reset all to defaults + void _showResetConfirmDialog( + BuildContext context, + SymbolBarViewModel model, + ) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Reset to Defaults?'), + content: const Text( + 'This will delete all custom symbol sets and reset to the predefined sets. This action cannot be undone.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () { + model.resetToDefaults(); + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Reset to defaults')), + ); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + ), + child: const Text('Reset'), + ), + ], + ), + ); + } +} diff --git a/mobile-app/lib/ui/widgets/drawer_widget/drawer_widget_view.dart b/mobile-app/lib/ui/widgets/drawer_widget/drawer_widget_view.dart index baf6e95c2..bc072eecf 100644 --- a/mobile-app/lib/ui/widgets/drawer_widget/drawer_widget_view.dart +++ b/mobile-app/lib/ui/widgets/drawer_widget/drawer_widget_view.dart @@ -76,7 +76,7 @@ class DrawerWidgetView extends StatelessWidget { buildDivider(), DrawerTile( key: const Key('daily-challenges'), - component: 'DAILY CHALLENGES', + component: context.t.quick_action_daily_challenges, icon: Icons.extension, route: () { model.routeComponent('DAILY_CHALLENGES', context); @@ -84,7 +84,7 @@ class DrawerWidgetView extends StatelessWidget { ), DrawerTile( key: const Key('learn'), - component: 'LEARN', + component: context.t.learn, icon: '', route: () { model.routeComponent('LEARN', context); @@ -92,7 +92,7 @@ class DrawerWidgetView extends StatelessWidget { ), DrawerTile( key: const Key('news'), - component: 'TUTORIALS', + component: context.t.tutorials, icon: Icons.forum_outlined, route: () { model.routeComponent('NEWS', context); @@ -100,7 +100,7 @@ class DrawerWidgetView extends StatelessWidget { ), DrawerTile( key: const Key('podcasts'), - component: 'PODCASTS', + component: context.t.podcasts, icon: Icons.podcasts_outlined, route: () { model.routeComponent('PODCAST', context); @@ -109,31 +109,31 @@ class DrawerWidgetView extends StatelessWidget { if (!Platform.isIOS) DrawerTile( key: const Key('code-radio'), - component: 'CODE RADIO', + component: context.t.code_radio, icon: Icons.radio, route: () { model.routeComponent('CODERADIO', context); }, ), buildDivider(), - const CustomTabButton( - key: Key('donate'), - component: 'DONATE', + CustomTabButton( + key: const Key('donate'), + component: context.t.donate, url: 'https://www.freecodecamp.org/donate/', icon: Icons.favorite, ), DrawerTile( key: const Key('settings'), - component: 'SETTINGS', + component: context.t.settings, icon: Icons.settings, route: () { model.routeComponent('SETTINGS', context); }, ), buildDivider(), - const CustomTabButton( - key: Key('report-issue'), - component: 'REPORT AN ISSUE', + CustomTabButton( + key: const Key('report-issue'), + component: context.t.report_an_issue, url: 'https://github.com/freeCodeCamp/mobile', icon: Icons.bug_report, ), diff --git a/mobile-app/lib/ui/widgets/drawer_widget/drawer_widget_viewmodel.dart b/mobile-app/lib/ui/widgets/drawer_widget/drawer_widget_viewmodel.dart index ef634769e..b6e7618cc 100644 --- a/mobile-app/lib/ui/widgets/drawer_widget/drawer_widget_viewmodel.dart +++ b/mobile-app/lib/ui/widgets/drawer_widget/drawer_widget_viewmodel.dart @@ -1,7 +1,9 @@ import 'package:flutter/material.dart'; import 'package:freecodecamp/app/app.locator.dart'; +import 'package:freecodecamp/l10n/app_localizations.dart'; import 'package:freecodecamp/service/authentication/authentication_service.dart'; import 'package:freecodecamp/service/developer_service.dart'; +import 'package:freecodecamp/service/locale_service.dart'; import 'package:freecodecamp/ui/views/code_radio/code_radio_view.dart'; import 'package:freecodecamp/ui/views/learn/daily_challenge/daily_challenge_view.dart'; import 'package:freecodecamp/ui/views/learn/landing/landing_view.dart'; @@ -17,6 +19,7 @@ class DrawerWidgetViewModel extends BaseViewModel { final AuthenticationService auth = locator(); final SnackbarService snack = locator(); + final LocaleService _localeService = locator(); bool _loggedIn = false; bool get loggedIn => _loggedIn; @@ -47,14 +50,16 @@ class DrawerWidgetViewModel extends BaseViewModel { } void snackbar() { + final t = lookupAppLocalizations(_localeService.locale); snack.showSnackbar( - title: 'Coming soon - use the web version', + title: t.coming_soon_web, message: '', ); } void loginSnack() { - snack.showSnackbar(message: '', title: 'Login will soon be available!'); + final t = lookupAppLocalizations(_localeService.locale); + snack.showSnackbar(message: '', title: t.login_coming_soon); } void routeComponent(view, context) async { diff --git a/mobile-app/lib/ui/widgets/setup_dialog_ui.dart b/mobile-app/lib/ui/widgets/setup_dialog_ui.dart index 011d52860..7c18512c9 100644 --- a/mobile-app/lib/ui/widgets/setup_dialog_ui.dart +++ b/mobile-app/lib/ui/widgets/setup_dialog_ui.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:freecodecamp/app/app.locator.dart'; import 'package:freecodecamp/enums/dialog_type.dart'; +import 'package:freecodecamp/extensions/i18n_extension.dart'; import 'package:freecodecamp/service/learn/learn_service.dart'; import 'package:freecodecamp/ui/theme/fcc_theme.dart'; import 'package:stacked_services/stacked_services.dart'; @@ -110,7 +111,7 @@ class _buttonDialog extends HookWidget { onDialogTap(DialogResponse(confirmed: false)), }, child: Text( - request.secondaryButtonTitle ?? 'Cancel', + request.secondaryButtonTitle ?? context.t.cancel, textAlign: TextAlign.center, style: const TextStyle(fontSize: 16), ), @@ -209,7 +210,7 @@ class _deleteAccountDialog extends HookWidget { onDialogTap(DialogResponse(confirmed: false)), }, child: Text( - request.secondaryButtonTitle ?? 'Cancel', + request.secondaryButtonTitle ?? context.t.cancel, textAlign: TextAlign.center, style: const TextStyle(fontSize: 16), ), @@ -384,14 +385,14 @@ class _askForHelpInputDialogue extends HookWidget { Flexible( child: RichText( text: TextSpan( - text: 'I have tried the ', + text: context.t.forum_tried, style: const TextStyle( fontSize: 14, color: FccColors.gray00, ), children: [ TextSpan( - text: 'Read-Search-Ask', + text: context.t.forum_read_search_ask, style: const TextStyle( fontSize: 14, color: FccColors.gray00, @@ -404,9 +405,10 @@ class _askForHelpInputDialogue extends HookWidget { '$forumLocation/t/how-to-get-help-when-you-are-stuck-coding/19514')); }, ), - const TextSpan( - text: ' method', - style: TextStyle(color: FccColors.gray00), + TextSpan( + text: context.t.forum_method, + style: + const TextStyle(color: FccColors.gray00), ), ], ), @@ -431,15 +433,14 @@ class _askForHelpInputDialogue extends HookWidget { Flexible( child: RichText( text: TextSpan( - text: 'I have searched for ', + text: context.t.forum_searched_for, style: const TextStyle( fontSize: 14, color: FccColors.gray00, ), children: [ TextSpan( - text: - 'similar questions that have already been answered on the forum', + text: context.t.forum_similar_questions, style: const TextStyle( fontSize: 14, color: FccColors.gray00, @@ -464,7 +465,9 @@ class _askForHelpInputDialogue extends HookWidget { ), if (charCount.value < 50) Text( - 'Please enter at least ${50 - charCount.value} more characters.', + context.t.forum_enter_more_chars( + (50 - charCount.value).toInt(), + ), style: const TextStyle( fontSize: 14, color: FccColors.gray00, @@ -478,9 +481,9 @@ class _askForHelpInputDialogue extends HookWidget { requestData.value = Map.from(requestData.value); charCount.value = value.length; }, - decoration: const InputDecoration( - hintText: 'Describe your issue in detail here...', - border: OutlineInputBorder(), + decoration: InputDecoration( + hintText: context.t.forum_issue_hint, + border: const OutlineInputBorder(), ), maxLines: 5, ), @@ -536,7 +539,7 @@ class _askForHelpInputDialogue extends HookWidget { onDialogTap(DialogResponse(confirmed: false)), }, child: Text( - request.secondaryButtonTitle ?? 'Cancel', + request.secondaryButtonTitle ?? context.t.cancel, textAlign: TextAlign.center, style: const TextStyle( fontSize: 16, @@ -588,14 +591,14 @@ class _askForHelpDialog extends HookWidget { ), RichText( text: TextSpan( - text: "If you've already tried the ", + text: context.t.forum_help_dialog_prefix, style: const TextStyle( fontSize: 16, color: FccColors.gray05, ), children: [ TextSpan( - text: 'Read-Search-Ask', + text: context.t.forum_read_search_ask, style: const TextStyle( fontSize: 16, color: FccColors.gray00, @@ -608,25 +611,23 @@ class _askForHelpDialog extends HookWidget { '$forumLocation/t/how-to-get-help-when-you-are-stuck-coding/19514')); }, ), - const TextSpan( - text: - ' method, then you can ask for help on the freeCodeCamp forum.', - style: TextStyle(color: FccColors.gray05), + TextSpan( + text: context.t.forum_help_dialog_suffix, + style: const TextStyle(color: FccColors.gray05), ), ], ), ), RichText( text: TextSpan( - text: 'Before making a new post please ', + text: context.t.forum_before_post, style: const TextStyle( fontSize: 16, color: FccColors.gray05, ), children: [ TextSpan( - text: - 'check if your question has already been answered on the forum', + text: context.t.forum_check_answered, style: const TextStyle( fontSize: 16, color: FccColors.gray00, @@ -689,7 +690,7 @@ class _askForHelpDialog extends HookWidget { onDialogTap(DialogResponse(confirmed: false)), }, child: Text( - request.secondaryButtonTitle ?? 'Cancel', + request.secondaryButtonTitle ?? context.t.cancel, textAlign: TextAlign.center, style: const TextStyle( fontSize: 16, diff --git a/mobile-app/pubspec.lock b/mobile-app/pubspec.lock index 068142aa5..3784544d4 100644 --- a/mobile-app/pubspec.lock +++ b/mobile-app/pubspec.lock @@ -1125,7 +1125,7 @@ packages: source: hosted version: "1.0.2" logger: - dependency: transitive + dependency: "direct main" description: name: logger sha256: "7ad7215c15420a102ec687bb320a7312afd449bac63bfb1c60d9787c27b9767f" @@ -1160,10 +1160,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.18.0" mime: dependency: transitive description: @@ -1709,10 +1709,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.11" timezone: dependency: "direct main" description: @@ -2018,5 +2018,5 @@ packages: source: hosted version: "3.1.2" sdks: - dart: ">=3.9.0 <4.0.0" + dart: ">=3.10.0-0 <4.0.0" flutter: ">=3.35.0" diff --git a/mobile-app/pubspec.yaml b/mobile-app/pubspec.yaml index e9341c369..939682b30 100644 --- a/mobile-app/pubspec.yaml +++ b/mobile-app/pubspec.yaml @@ -1,7 +1,7 @@ name: freecodecamp description: freecodecamp.org app. publish_to: none -version: 7.5.1+71501 +version: 7.5.2+71502 environment: sdk: ">=3.0.0 <4.0.0" dependencies: @@ -38,6 +38,7 @@ dependencies: infinite_scroll_pagination: 5.0.0 jiffy: 6.3.2 just_audio: 0.10.4 + logger: ^1.4.0 path: 1.9.1 path_provider: 2.1.5 phone_ide: 2.0.1 @@ -81,7 +82,6 @@ flutter: uses-material-design: true generate: true assets: - - .env - assets/images/ - assets/sql/ - assets/test_data/news_post.json @@ -90,3 +90,4 @@ flutter: - assets/learn/ - assets/test_runner/dist/ - assets/test_runner/babel/ + - .env diff --git a/mobile-app/test/helpers/test_helpers.dart b/mobile-app/test/helpers/test_helpers.dart index 22e51461b..d51560318 100644 --- a/mobile-app/test/helpers/test_helpers.dart +++ b/mobile-app/test/helpers/test_helpers.dart @@ -2,6 +2,7 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:freecodecamp/app/app.locator.dart'; import 'package:freecodecamp/service/authentication/authentication_service.dart'; import 'package:freecodecamp/service/learn/daily_challenge_service.dart'; +import 'package:freecodecamp/service/locale_service.dart'; import 'package:freecodecamp/service/news/bookmark_service.dart'; import 'package:mockito/annotations.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -26,6 +27,7 @@ void registerServices() { getAndRegisterNewsBookmarkService(); getAndRegisterAuthenticationService(); getAndRegisterDailyChallengeService(); + getAndRegisterLocaleService(); // @stacked-mock-register } @@ -63,6 +65,13 @@ MockDailyChallengeService getAndRegisterDailyChallengeService() { locator.registerSingleton(service); return service; } + +LocaleService getAndRegisterLocaleService() { + _removeRegistrationIfExists(); + final service = LocaleService(); + locator.registerSingleton(service); + return service; +} // @stacked-mock-create void _removeRegistrationIfExists() { diff --git a/mobile-app/test/services/learn/daily_challenge_notification_service_test.dart b/mobile-app/test/services/learn/daily_challenge_notification_service_test.dart index 69ebcb23c..d5ea72d12 100644 --- a/mobile-app/test/services/learn/daily_challenge_notification_service_test.dart +++ b/mobile-app/test/services/learn/daily_challenge_notification_service_test.dart @@ -8,6 +8,7 @@ import 'package:freecodecamp/models/main/user_model.dart'; import 'package:freecodecamp/service/authentication/authentication_service.dart'; import 'package:freecodecamp/service/learn/daily_challenge_notification_service.dart'; import 'package:freecodecamp/service/learn/daily_challenge_service.dart'; +import 'package:freecodecamp/service/locale_service.dart'; import 'package:mockito/mockito.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:timezone/data/latest.dart' as tz; @@ -23,10 +24,12 @@ class TestDailyChallengeNotificationService required FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin, required DailyChallengeService dailyChallengeService, required AuthenticationService authenticationService, + required LocaleService localeService, }) : super( flutterLocalNotificationsPlugin: flutterLocalNotificationsPlugin, dailyChallengeService: dailyChallengeService, authenticationService: authenticationService, + localeService: localeService, ); @override @@ -46,6 +49,7 @@ void main() { late MockFlutterLocalNotificationsPlugin mockNotifications; late MockDailyChallengeService mockDailyChallengeService; late MockAuthenticationService mockAuthenticationService; + late LocaleService localeService; late tz.Location tzLocation; late DailyChallengeNotificationService service; late SharedPreferences prefs; @@ -58,10 +62,12 @@ void main() { mockNotifications = MockFlutterLocalNotificationsPlugin(); mockDailyChallengeService = MockDailyChallengeService(); mockAuthenticationService = MockAuthenticationService(); + localeService = LocaleService(); service = DailyChallengeNotificationService( flutterLocalNotificationsPlugin: mockNotifications, dailyChallengeService: mockDailyChallengeService, authenticationService: mockAuthenticationService, + localeService: localeService, ); mockAndroidNotifications = MockAndroidFlutterLocalNotificationsPlugin(); @@ -76,6 +82,7 @@ void main() { flutterLocalNotificationsPlugin: mockNotifications, dailyChallengeService: mockDailyChallengeService, authenticationService: mockAuthenticationService, + localeService: localeService, ); }); diff --git a/mobile-app/test/services/symbol_bar_service_test.dart b/mobile-app/test/services/symbol_bar_service_test.dart new file mode 100644 index 000000000..e45b16583 --- /dev/null +++ b/mobile-app/test/services/symbol_bar_service_test.dart @@ -0,0 +1,394 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:freecodecamp/models/symbol_set_model.dart'; +import 'package:freecodecamp/service/symbol_bar_service.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'symbol_bar_service_test.mocks.dart'; + +@GenerateMocks([SharedPreferences]) +void main() { + group('SymbolSetModel Tests', () { + test('SymbolSetType.fromValue should parse string correctly', () { + expect(SymbolSetType.fromValue('python'), SymbolSetType.python); + expect(SymbolSetType.fromValue('PYTHON'), SymbolSetType.python); + expect(SymbolSetType.fromValue('javascript'), SymbolSetType.javascript); + expect(SymbolSetType.fromValue('htmlCss'), SymbolSetType.htmlCss); + expect(SymbolSetType.fromValue('invalid'), + SymbolSetType.python); // Default fallback + }); + + test('PredefinedSymbolSet should return correct symbols', () { + final pythonSet = PredefinedSymbolSet(SymbolSetType.python); + expect(pythonSet.symbols.length, 12); + expect(pythonSet.symbols.contains('def'), true); + expect(pythonSet.symbols.contains('class'), true); + + final jsSet = PredefinedSymbolSet(SymbolSetType.javascript); + expect(jsSet.symbols.length, 12); + expect(jsSet.symbols.contains('=>'), true); + expect(jsSet.symbols.contains('const'), true); + + final htmlCssSet = PredefinedSymbolSet(SymbolSetType.htmlCss); + expect(htmlCssSet.symbols.length, 12); + expect(htmlCssSet.symbols.contains('div'), true); + expect(htmlCssSet.symbols.contains('class='), true); + }); + + test('PredefinedSymbolSet should not be custom', () { + final set = PredefinedSymbolSet(SymbolSetType.python); + expect(set.isCustom, false); + }); + + test('PredefinedSymbolSet.copyWith should return self', () { + final set = PredefinedSymbolSet(SymbolSetType.python); + final copied = set.copyWith(symbols: ['new']); + expect(identical(set, copied), true); + }); + + test('PredefinedSymbolSet.toJson should serialize correctly', () { + final set = PredefinedSymbolSet(SymbolSetType.python); + final json = set.toJson(); + expect(json['type'], 'python'); + expect(json['isCustom'], false); + expect(json['symbols'], isA()); + }); + + test('CustomSymbolSet should be custom', () { + final set = CustomSymbolSet(name: 'My Set', symbols: ['a', 'b']); + expect(set.isCustom, true); + }); + + test('CustomSymbolSet.copyWith should create new instance', () { + final set = CustomSymbolSet(name: 'My Set', symbols: ['a', 'b']); + final copied = set.copyWith(symbols: ['c', 'd']); + expect(identical(set, copied), false); + expect(copied.name, 'My Set'); + expect(copied.symbols, ['c', 'd']); + }); + + test('CustomSymbolSet.toJson should serialize correctly', () { + final set = CustomSymbolSet(name: 'My Set', symbols: ['a', 'b']); + final json = set.toJson(); + expect(json['name'], 'My Set'); + expect(json['isCustom'], true); + expect(json['symbols'], ['a', 'b']); + }); + + test('CustomSymbolSet.fromJson should deserialize correctly', () { + final json = { + 'name': 'My Set', + 'symbols': ['a', 'b', 'c'], + 'isCustom': true, + }; + final set = CustomSymbolSet.fromJson(json); + expect(set.name, 'My Set'); + expect(set.symbols, ['a', 'b', 'c']); + }); + + test('SymbolSet.fromJson should create correct instance', () { + final predefinedJson = { + 'type': 'python', + 'isCustom': false, + }; + final predefined = SymbolSet.fromJson(predefinedJson); + expect(predefined is PredefinedSymbolSet, true); + + final customJson = { + 'name': 'Custom', + 'symbols': ['x', 'y'], + 'isCustom': true, + }; + final custom = SymbolSet.fromJson(customJson); + expect(custom is CustomSymbolSet, true); + }); + + test('SymbolBarState should have correct currentSymbols', () { + final set = PredefinedSymbolSet(SymbolSetType.python); + final state = SymbolBarState(activeSet: set); + expect(state.currentSymbols, set.symbols); + }); + + test('SymbolBarState.copyWith should create new instance', () { + final set1 = PredefinedSymbolSet(SymbolSetType.python); + final set2 = PredefinedSymbolSet(SymbolSetType.javascript); + final state1 = SymbolBarState(activeSet: set1); + final state2 = state1.copyWith(activeSet: set2); + + expect(identical(state1, state2), false); + expect(state1.activeSet, set1); + expect(state2.activeSet, set2); + }); + + test('SymbolBarState.defaultState should return Python set', () { + final state = SymbolBarState.defaultState(); + expect(state.activeSet is PredefinedSymbolSet, true); + expect( + (state.activeSet as PredefinedSymbolSet).type, + SymbolSetType.python, + ); + expect(state.customSymbolsEnabled, false); + expect(state.customSymbolSets.isEmpty, true); + }); + + test('SymbolBarState.toJson and fromJson should round-trip', () { + final customSet = CustomSymbolSet(name: 'Test Set', symbols: ['a', 'b']); + final originalState = SymbolBarState( + activeSet: customSet, + customSymbolsEnabled: true, + customSymbolSets: [customSet], + ); + + final json = originalState.toJson(); + final restoredState = SymbolBarState.fromJson(json); + + expect(restoredState.activeSet is CustomSymbolSet, true); + expect((restoredState.activeSet as CustomSymbolSet).name, 'Test Set'); + expect(restoredState.customSymbolsEnabled, true); + expect(restoredState.customSymbolSets.length, 1); + }); + }); + + group('SymbolBarService Tests', () { + late MockSharedPreferences mockPrefs; + late SymbolBarService service; + + setUp(() { + mockPrefs = MockSharedPreferences(); + service = SymbolBarService(); + }); + + test('init should load default state when no saved state exists', () async { + // Setup mock to return null (no saved state) + when(mockPrefs.getString(any)).thenReturn(null); + when(mockPrefs.getBool(any)).thenReturn(null); + + await service.init(mockPrefs); + + expect(service.state.activeSet is PredefinedSymbolSet, true); + expect( + (service.state.activeSet as PredefinedSymbolSet).type, + SymbolSetType.python, + ); + }); + + test('switchToPredefinedSet should change active set', () async { + when(mockPrefs.getString(any)).thenReturn(null); + when(mockPrefs.getBool(any)).thenReturn(null); + when(mockPrefs.setString(any, any)).thenAnswer((_) async => true); + when(mockPrefs.setBool(any, any)).thenAnswer((_) async => true); + + await service.init(mockPrefs); + + await service.switchToPredefinedSet(SymbolSetType.javascript); + + expect(service.state.activeSet is PredefinedSymbolSet, true); + expect( + (service.state.activeSet as PredefinedSymbolSet).type, + SymbolSetType.javascript, + ); + }); + + test('createCustomSet should add new custom set', () async { + when(mockPrefs.getString(any)).thenReturn(null); + when(mockPrefs.getBool(any)).thenReturn(null); + when(mockPrefs.setString(any, any)).thenAnswer((_) async => true); + when(mockPrefs.setBool(any, any)).thenAnswer((_) async => true); + + await service.init(mockPrefs); + + await service.createCustomSet( + name: 'Test Set', + symbols: ['a', 'b', 'c'], + ); + + expect(service.state.customSymbolSets.length, 1); + expect(service.state.customSymbolSets[0].name, 'Test Set'); + expect(service.state.customSymbolSets[0].symbols, ['a', 'b', 'c']); + }); + + test('createCustomSet should reject empty name', () async { + when(mockPrefs.getString(any)).thenReturn(null); + when(mockPrefs.getBool(any)).thenReturn(null); + + await service.init(mockPrefs); + + expect( + () => service.createCustomSet( + name: '', + symbols: ['a'], + ), + throwsA(isA()), + ); + }); + + test('createCustomSet should reject empty symbols', () async { + when(mockPrefs.getString(any)).thenReturn(null); + when(mockPrefs.getBool(any)).thenReturn(null); + + await service.init(mockPrefs); + + expect( + () => service.createCustomSet( + name: 'Test', + symbols: [], + ), + throwsA(isA()), + ); + }); + + test('createCustomSet should reject duplicate names', () async { + when(mockPrefs.getString(any)).thenReturn(null); + when(mockPrefs.getBool(any)).thenReturn(null); + when(mockPrefs.setString(any, any)).thenAnswer((_) async => true); + when(mockPrefs.setBool(any, any)).thenAnswer((_) async => true); + + await service.init(mockPrefs); + + await service.createCustomSet( + name: 'Test Set', + symbols: ['a', 'b'], + ); + + expect( + () => service.createCustomSet( + name: 'Test Set', + symbols: ['c', 'd'], + ), + throwsA(isA()), + ); + }); + + test('updateCustomSet should modify existing set', () async { + when(mockPrefs.getString(any)).thenReturn(null); + when(mockPrefs.getBool(any)).thenReturn(null); + when(mockPrefs.setString(any, any)).thenAnswer((_) async => true); + when(mockPrefs.setBool(any, any)).thenAnswer((_) async => true); + + await service.init(mockPrefs); + + await service.createCustomSet( + name: 'Original', + symbols: ['a', 'b'], + ); + + await service.updateCustomSet( + currentName: 'Original', + newName: 'Updated', + newSymbols: ['c', 'd', 'e'], + ); + + expect(service.state.customSymbolSets[0].name, 'Updated'); + expect(service.state.customSymbolSets[0].symbols, ['c', 'd', 'e']); + }); + + test('deleteCustomSet should remove set', () async { + when(mockPrefs.getString(any)).thenReturn(null); + when(mockPrefs.getBool(any)).thenReturn(null); + when(mockPrefs.setString(any, any)).thenAnswer((_) async => true); + when(mockPrefs.setBool(any, any)).thenAnswer((_) async => true); + + await service.init(mockPrefs); + + await service.createCustomSet( + name: 'Test Set', + symbols: ['a', 'b'], + ); + + await service.deleteCustomSet('Test Set'); + + expect(service.state.customSymbolSets.isEmpty, true); + }); + + test('deleteCustomSet should switch to Python if active', () async { + when(mockPrefs.getString(any)).thenReturn(null); + when(mockPrefs.getBool(any)).thenReturn(null); + when(mockPrefs.setString(any, any)).thenAnswer((_) async => true); + when(mockPrefs.setBool(any, any)).thenAnswer((_) async => true); + + await service.init(mockPrefs); + + final customSet = CustomSymbolSet( + name: 'Active Set', + symbols: ['a', 'b'], + ); + final newState = service.state.copyWith(activeSet: customSet); + service.state == newState; + + await service.deleteCustomSet('Active Set'); + + expect(service.state.activeSet is PredefinedSymbolSet, true); + expect( + (service.state.activeSet as PredefinedSymbolSet).type, + SymbolSetType.python, + ); + }); + + test('getAllSymbolSets should return all sets', () async { + when(mockPrefs.getString(any)).thenReturn(null); + when(mockPrefs.getBool(any)).thenReturn(null); + when(mockPrefs.setString(any, any)).thenAnswer((_) async => true); + when(mockPrefs.setBool(any, any)).thenAnswer((_) async => true); + + await service.init(mockPrefs); + + await service.createCustomSet( + name: 'Custom 1', + symbols: ['a'], + ); + + final allSets = service.getAllSymbolSets(); + + // 3 predefined + 1 custom + expect(allSets.length, 4); + expect( + allSets.whereType().length, + 3, + ); + expect( + allSets.whereType().length, + 1, + ); + }); + + test('resetToDefaults should clear all custom sets', () async { + when(mockPrefs.getString(any)).thenReturn(null); + when(mockPrefs.getBool(any)).thenReturn(null); + when(mockPrefs.setString(any, any)).thenAnswer((_) async => true); + when(mockPrefs.setBool(any, any)).thenAnswer((_) async => true); + + await service.init(mockPrefs); + + await service.createCustomSet( + name: 'Custom Set', + symbols: ['a'], + ); + + await service.resetToDefaults(); + + expect(service.state.customSymbolSets.isEmpty, true); + expect(service.state.activeSet is PredefinedSymbolSet, true); + expect( + (service.state.activeSet as PredefinedSymbolSet).type, + SymbolSetType.python, + ); + }); + + test('listeners should be notified on state change', () async { + when(mockPrefs.getString(any)).thenReturn(null); + when(mockPrefs.getBool(any)).thenReturn(null); + when(mockPrefs.setString(any, any)).thenAnswer((_) async => true); + when(mockPrefs.setBool(any, any)).thenAnswer((_) async => true); + + await service.init(mockPrefs); + + var callCount = 0; + service.addListener((_) => callCount++); + + await service.switchToPredefinedSet(SymbolSetType.javascript); + + expect(callCount, greaterThan(0)); + }); + }); +} diff --git a/mobile-app/test/services/symbol_bar_service_test.mocks.dart b/mobile-app/test/services/symbol_bar_service_test.mocks.dart new file mode 100644 index 000000000..db5c03140 --- /dev/null +++ b/mobile-app/test/services/symbol_bar_service_test.mocks.dart @@ -0,0 +1,203 @@ +// Mocks generated by Mockito 5.4.4 from annotations +// in freecodecamp/test/services/symbol_bar_service_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i3; + +import 'package:mockito/mockito.dart' as _i1; +import 'package:shared_preferences/src/shared_preferences_legacy.dart' as _i2; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +/// A class which mocks [SharedPreferences]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockSharedPreferences extends _i1.Mock implements _i2.SharedPreferences { + MockSharedPreferences() { + _i1.throwOnMissingStub(this); + } + + @override + Set getKeys() => (super.noSuchMethod( + Invocation.method( + #getKeys, + [], + ), + returnValue: {}, + ) as Set); + + @override + Object? get(String? key) => (super.noSuchMethod(Invocation.method( + #get, + [key], + )) as Object?); + + @override + bool? getBool(String? key) => (super.noSuchMethod(Invocation.method( + #getBool, + [key], + )) as bool?); + + @override + int? getInt(String? key) => (super.noSuchMethod(Invocation.method( + #getInt, + [key], + )) as int?); + + @override + double? getDouble(String? key) => (super.noSuchMethod(Invocation.method( + #getDouble, + [key], + )) as double?); + + @override + String? getString(String? key) => (super.noSuchMethod(Invocation.method( + #getString, + [key], + )) as String?); + + @override + bool containsKey(String? key) => (super.noSuchMethod( + Invocation.method( + #containsKey, + [key], + ), + returnValue: false, + ) as bool); + + @override + List? getStringList(String? key) => + (super.noSuchMethod(Invocation.method( + #getStringList, + [key], + )) as List?); + + @override + _i3.Future setBool( + String? key, + bool? value, + ) => + (super.noSuchMethod( + Invocation.method( + #setBool, + [ + key, + value, + ], + ), + returnValue: _i3.Future.value(false), + ) as _i3.Future); + + @override + _i3.Future setInt( + String? key, + int? value, + ) => + (super.noSuchMethod( + Invocation.method( + #setInt, + [ + key, + value, + ], + ), + returnValue: _i3.Future.value(false), + ) as _i3.Future); + + @override + _i3.Future setDouble( + String? key, + double? value, + ) => + (super.noSuchMethod( + Invocation.method( + #setDouble, + [ + key, + value, + ], + ), + returnValue: _i3.Future.value(false), + ) as _i3.Future); + + @override + _i3.Future setString( + String? key, + String? value, + ) => + (super.noSuchMethod( + Invocation.method( + #setString, + [ + key, + value, + ], + ), + returnValue: _i3.Future.value(false), + ) as _i3.Future); + + @override + _i3.Future setStringList( + String? key, + List? value, + ) => + (super.noSuchMethod( + Invocation.method( + #setStringList, + [ + key, + value, + ], + ), + returnValue: _i3.Future.value(false), + ) as _i3.Future); + + @override + _i3.Future remove(String? key) => (super.noSuchMethod( + Invocation.method( + #remove, + [key], + ), + returnValue: _i3.Future.value(false), + ) as _i3.Future); + + @override + _i3.Future commit() => (super.noSuchMethod( + Invocation.method( + #commit, + [], + ), + returnValue: _i3.Future.value(false), + ) as _i3.Future); + + @override + _i3.Future clear() => (super.noSuchMethod( + Invocation.method( + #clear, + [], + ), + returnValue: _i3.Future.value(false), + ) as _i3.Future); + + @override + _i3.Future reload() => (super.noSuchMethod( + Invocation.method( + #reload, + [], + ), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) as _i3.Future); +} diff --git a/mobile-app/test/widget/daily_challenge_card_test.dart b/mobile-app/test/widget/daily_challenge_card_test.dart index faa8b7824..6d09e7ca8 100644 --- a/mobile-app/test/widget/daily_challenge_card_test.dart +++ b/mobile-app/test/widget/daily_challenge_card_test.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:freecodecamp/app/app.locator.dart'; import 'package:freecodecamp/app/app.router.dart'; +import 'package:freecodecamp/l10n/app_localizations.dart'; import 'package:freecodecamp/models/learn/daily_challenge_model.dart'; import 'package:freecodecamp/ui/views/learn/widgets/daily_challenge_card.dart'; import 'package:mockito/mockito.dart'; @@ -49,6 +50,8 @@ void main() { bool isCompleted = false, }) { return MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, home: Scaffold( body: DailyChallengeCard( dailyChallenge: challenge, diff --git a/mobile-app/test/widget/daily_challenge_view_test.dart b/mobile-app/test/widget/daily_challenge_view_test.dart index a42bcaaef..3b8a218e4 100644 --- a/mobile-app/test/widget/daily_challenge_view_test.dart +++ b/mobile-app/test/widget/daily_challenge_view_test.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:freecodecamp/app/app.locator.dart'; +import 'package:freecodecamp/l10n/app_localizations.dart'; import 'package:freecodecamp/models/learn/daily_challenge_model.dart'; import 'package:freecodecamp/models/main/user_model.dart'; import 'package:freecodecamp/service/authentication/authentication_service.dart'; @@ -41,6 +42,8 @@ void main() { Widget createTestWidget() { return const MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, home: DailyChallengeView(), ); } diff --git a/mobile-app/untranslated.json b/mobile-app/untranslated.json new file mode 100644 index 000000000..9e26dfeeb --- /dev/null +++ b/mobile-app/untranslated.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 353cca44d..bc03cb826 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,8 +16,7 @@ "npm-run-all2": "6.2.0" }, "engines": { - "node": ">=16", - "npm": ">=8" + "node": ">=24" } }, "node_modules/ansi-escapes": {