├── .husky └── pre-commit ├── mobile-app ├── assets │ ├── database │ │ ├── podcasts.db │ │ └── bookmarked-article.db │ ├── sql │ │ ├── 2_delete_all_values.sql │ │ ├── 4_delete_all_values.sql │ │ ├── 3_reset_episodes_schema.sql │ │ └── 1_create_db_schema.sql │ ├── images │ │ ├── logo.jpg │ │ ├── logo.png │ │ ├── app-logo.png │ │ ├── apple-logo.png │ │ ├── github-logo.png │ │ ├── google-logo.png │ │ ├── splash_screen.png │ │ ├── episode_default.jpg │ │ ├── freecodecamp-banner.png │ │ └── placeholder-profile-img.png │ └── fonts │ │ ├── Inter-Bold.ttf │ │ ├── Lato-Regular.ttf │ │ ├── Inter-Regular.ttf │ │ ├── RobotoMono-Bold.ttf │ │ └── RobotoMono-Regular.ttf ├── android │ ├── Gemfile │ ├── fastlane │ │ ├── Appfile │ │ ├── Fastfile │ │ └── README.md │ ├── gradle.properties │ ├── app │ │ ├── src │ │ │ ├── main │ │ │ │ ├── res │ │ │ │ │ ├── mipmap-hdpi │ │ │ │ │ │ ├── ic_launcher.png │ │ │ │ │ │ └── launcher_icon.png │ │ │ │ │ ├── mipmap-mdpi │ │ │ │ │ │ ├── ic_launcher.png │ │ │ │ │ │ └── launcher_icon.png │ │ │ │ │ ├── mipmap-xhdpi │ │ │ │ │ │ ├── ic_launcher.png │ │ │ │ │ │ └── launcher_icon.png │ │ │ │ │ ├── mipmap-xxhdpi │ │ │ │ │ │ ├── ic_launcher.png │ │ │ │ │ │ └── launcher_icon.png │ │ │ │ │ ├── mipmap-xxxhdpi │ │ │ │ │ │ ├── ic_launcher.png │ │ │ │ │ │ └── launcher_icon.png │ │ │ │ │ ├── drawable │ │ │ │ │ │ └── launch_background.xml │ │ │ │ │ ├── drawable-v21 │ │ │ │ │ │ └── launch_background.xml │ │ │ │ │ ├── values-night │ │ │ │ │ │ └── styles.xml │ │ │ │ │ └── values │ │ │ │ │ │ └── styles.xml │ │ │ │ ├── kotlin │ │ │ │ │ └── com │ │ │ │ │ │ └── example │ │ │ │ │ │ └── freecodecamp │ │ │ │ │ │ └── MainActivity.kt │ │ │ │ └── AndroidManifest.xml │ │ │ ├── debug │ │ │ │ └── AndroidManifest.xml │ │ │ └── profile │ │ │ │ └── AndroidManifest.xml │ │ ├── google-services.json │ │ └── build.gradle │ ├── gradle │ │ └── wrapper │ │ │ └── gradle-wrapper.properties │ ├── .gitignore │ ├── build.gradle │ └── settings.gradle ├── ios │ ├── Runner │ │ ├── Runner-Bridging-Header.h │ │ ├── Assets.xcassets │ │ │ ├── LaunchImage.imageset │ │ │ │ ├── LaunchImage.png │ │ │ │ ├── LaunchImage@2x.png │ │ │ │ ├── LaunchImage@3x.png │ │ │ │ ├── README.md │ │ │ │ └── Contents.json │ │ │ └── AppIcon.appiconset │ │ │ │ ├── Icon-App-20x20@1x.png │ │ │ │ ├── Icon-App-20x20@2x.png │ │ │ │ ├── Icon-App-20x20@3x.png │ │ │ │ ├── Icon-App-29x29@1x.png │ │ │ │ ├── Icon-App-29x29@2x.png │ │ │ │ ├── Icon-App-29x29@3x.png │ │ │ │ ├── Icon-App-40x40@1x.png │ │ │ │ ├── Icon-App-40x40@2x.png │ │ │ │ ├── Icon-App-40x40@3x.png │ │ │ │ ├── Icon-App-50x50@1x.png │ │ │ │ ├── Icon-App-50x50@2x.png │ │ │ │ ├── Icon-App-57x57@1x.png │ │ │ │ ├── Icon-App-57x57@2x.png │ │ │ │ ├── Icon-App-60x60@2x.png │ │ │ │ ├── Icon-App-60x60@3x.png │ │ │ │ ├── Icon-App-72x72@1x.png │ │ │ │ ├── Icon-App-72x72@2x.png │ │ │ │ ├── Icon-App-76x76@1x.png │ │ │ │ ├── Icon-App-76x76@2x.png │ │ │ │ ├── Icon-App-1024x1024@1x.png │ │ │ │ ├── Icon-App-83.5x83.5@2x.png │ │ │ │ └── Contents.json │ │ ├── AppDelegate.swift │ │ ├── GoogleService-Info.plist │ │ ├── Base.lproj │ │ │ ├── Main.storyboard │ │ │ └── LaunchScreen.storyboard │ │ ├── Info-Release.plist │ │ ├── Info-Debug.plist │ │ └── Info-Profile.plist │ ├── Flutter │ │ ├── Debug.xcconfig │ │ ├── Release.xcconfig │ │ └── AppFrameworkInfo.plist │ ├── Runner.xcodeproj │ │ ├── project.xcworkspace │ │ │ ├── contents.xcworkspacedata │ │ │ └── xcshareddata │ │ │ │ ├── WorkspaceSettings.xcsettings │ │ │ │ └── IDEWorkspaceChecks.plist │ │ └── xcshareddata │ │ │ └── xcschemes │ │ │ └── Runner.xcscheme │ ├── Runner.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ ├── WorkspaceSettings.xcsettings │ │ │ └── IDEWorkspaceChecks.plist │ ├── firebase_app_id_file.json │ ├── .gitignore │ └── Podfile ├── lib │ ├── enums │ │ ├── panel_type.dart │ │ ├── dialog_type.dart │ │ ├── ext_type.dart │ │ ├── theme_type.dart │ │ └── alert_type.dart │ ├── ui │ │ ├── views │ │ │ ├── podcast │ │ │ │ ├── episode │ │ │ │ │ └── episode_viewmodel.dart │ │ │ │ ├── podcast-list │ │ │ │ │ └── podcast_list_viewmodel.dart │ │ │ │ └── episode-list │ │ │ │ │ └── episode_list_viewmodel.dart │ │ │ ├── news │ │ │ │ ├── news-image-viewer │ │ │ │ │ ├── news_image_viewmodel.dart │ │ │ │ │ └── news_image_view.dart │ │ │ │ ├── news-view-handler │ │ │ │ │ ├── news_view_handler_viewmodel.dart │ │ │ │ │ └── news_view_handler_view.dart │ │ │ │ ├── news-author │ │ │ │ │ └── news_author_viewmodel.dart │ │ │ │ ├── widgets │ │ │ │ │ └── back_to_top_button.dart │ │ │ │ ├── news-bookmark │ │ │ │ │ ├── news_bookmark_widget.dart │ │ │ │ │ ├── news_bookmark_feed_view.dart │ │ │ │ │ └── news_bookmark_viewmodel.dart │ │ │ │ └── news-search │ │ │ │ │ └── news_search_viewmodel.dart │ │ │ ├── learn │ │ │ │ ├── widgets │ │ │ │ │ ├── dynamic_panel │ │ │ │ │ │ └── panels │ │ │ │ │ │ │ ├── hint │ │ │ │ │ │ │ └── hint_widget_model.dart │ │ │ │ │ │ │ ├── description │ │ │ │ │ │ │ └── description_widget_model.dart │ │ │ │ │ │ │ ├── pass │ │ │ │ │ │ │ └── pass_widget_model.dart │ │ │ │ │ │ │ └── dynamic_panel.dart │ │ │ │ │ ├── console │ │ │ │ │ │ ├── console_viewmodel.dart │ │ │ │ │ │ └── console_view.dart │ │ │ │ │ ├── open_close_icon_widget.dart │ │ │ │ │ ├── custom_alert_widget.dart │ │ │ │ │ ├── audio │ │ │ │ │ │ └── audio_player_viewmodel.dart │ │ │ │ │ ├── progressbar_widget.dart │ │ │ │ │ └── download_button_widget.dart │ │ │ │ ├── utils │ │ │ │ │ └── learn_globals.dart │ │ │ │ ├── challenge │ │ │ │ │ └── templates │ │ │ │ │ │ ├── python │ │ │ │ │ │ └── python_viewmodel.dart │ │ │ │ │ │ ├── multiple_choice │ │ │ │ │ │ └── multiple_choice_viewmodel.dart │ │ │ │ │ │ └── python-project │ │ │ │ │ │ └── python_project_viewmodel.dart │ │ │ │ └── superblock │ │ │ │ │ └── superblock_viewmodel.dart │ │ │ ├── profile │ │ │ │ └── profile_viewmodel.dart │ │ │ ├── settings │ │ │ │ ├── settings_viewmodel.dart │ │ │ │ └── delete-account │ │ │ │ │ └── delete_account_viewmodel.dart │ │ │ ├── login │ │ │ │ └── native_login_viewmodel.dart │ │ │ └── code_radio │ │ │ │ └── code_radio_viewmodel.dart │ │ ├── widgets │ │ │ ├── drawer_widget │ │ │ │ ├── drawer_tile.dart │ │ │ │ └── drawer_web_buttton.dart │ │ │ └── tag_widget.dart │ │ └── theme │ │ │ └── fcc_theme.dart │ ├── extensions │ │ └── i18n_extension.dart │ ├── utils │ │ └── upgrade_controller.dart │ ├── service │ │ ├── developer_service.dart │ │ ├── firebase │ │ │ ├── analytics_service.dart │ │ │ ├── remote_config_service.dart │ │ │ └── analytics_observer.dart │ │ ├── dio_service.dart │ │ ├── locale_service.dart │ │ ├── navigation │ │ │ └── quick_actions_service.dart │ │ └── podcast │ │ │ ├── download_service.dart │ │ │ └── notification_service.dart │ ├── constants │ │ └── radio_articles.dart │ ├── models │ │ ├── learn │ │ │ ├── motivational_quote_model.dart │ │ │ ├── saved_challenge_model.dart │ │ │ └── completed_challenge_model.dart │ │ ├── main │ │ │ ├── portfolio_model.dart │ │ │ └── profile_ui_model.dart │ │ ├── news │ │ │ └── bookmarked_tutorial_model.dart │ │ ├── code-radio │ │ │ └── code_radio_model.dart │ │ └── podcasts │ │ │ ├── podcasts_model.dart │ │ │ └── episodes_model.dart │ ├── firebase_options.dart │ └── app │ │ └── app.locator.dart ├── l10n.yaml ├── devtools_options.yaml ├── sample.env ├── flutter_launcher_icons.yaml ├── .metadata ├── firebase.json ├── analysis_options.yaml ├── test_driver │ └── integration_test.dart ├── test │ └── helpers │ │ └── test_helpers.dart ├── integration_test │ └── learn │ │ └── learn_landing.dart ├── integration_test_runner.dart └── pubspec.yaml ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ ├── api-build-and-test.yml │ ├── crowdin-upload.yml │ ├── deploy-api.yml │ └── mobile-curriculum-e2e.yml ├── mobile-api ├── .dockerignore ├── .prettierignore ├── .prettierrc.json ├── sample.env ├── babel.config.js ├── jest.config.js ├── docker-compose.yml ├── src │ ├── podcast-feed-urls.json │ ├── db-connect.ts │ ├── schema-keys.json │ ├── models │ │ ├── Episode.ts │ │ └── Podcast.ts │ ├── index.ts │ ├── jobs │ │ └── update-podcasts.ts │ ├── routes.ts │ └── __tests__ │ │ └── podcast.test.ts ├── docker-compose.dev.yml ├── .eslintrc.js ├── Dockerfile └── package.json ├── .lintstagedrc.json ├── renovate.json ├── .editorconfig ├── .gitignore ├── package.json ├── LICENSE.md └── README.md /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx lint-staged 2 | -------------------------------------------------------------------------------- /mobile-app/assets/database/podcasts.db: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @freecodecamp/mobile 2 | -------------------------------------------------------------------------------- /mobile-api/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm.debug.log 3 | .env -------------------------------------------------------------------------------- /.lintstagedrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "!(app.*)*": "flutter analyze" 3 | } 4 | -------------------------------------------------------------------------------- /mobile-api/.prettierignore: -------------------------------------------------------------------------------- 1 | *.config.js 2 | src/schema-keys.json 3 | -------------------------------------------------------------------------------- /mobile-api/.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } 4 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["github>freecodecamp/renovate-config"] 3 | } 4 | -------------------------------------------------------------------------------- /mobile-app/android/Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "fastlane" 4 | -------------------------------------------------------------------------------- /mobile-app/ios/Runner/Runner-Bridging-Header.h: -------------------------------------------------------------------------------- 1 | #import "GeneratedPluginRegistrant.h" 2 | -------------------------------------------------------------------------------- /mobile-app/assets/sql/2_delete_all_values.sql: -------------------------------------------------------------------------------- 1 | DELETE FROM podcasts; 2 | DELETE FROM episodes; -------------------------------------------------------------------------------- /mobile-app/lib/enums/panel_type.dart: -------------------------------------------------------------------------------- 1 | enum PanelType { pass, instruction, hint, none } 2 | -------------------------------------------------------------------------------- /mobile-app/assets/sql/4_delete_all_values.sql: -------------------------------------------------------------------------------- 1 | DELETE FROM podcasts; 2 | DELETE FROM episodes; 3 | -------------------------------------------------------------------------------- /mobile-api/sample.env: -------------------------------------------------------------------------------- 1 | MONGODB_URL=mongodb://localhost:27017/mobile-api 2 | DEV_URL=http://localhost:3000 3 | -------------------------------------------------------------------------------- /mobile-app/assets/images/logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ialalendd/mobile/HEAD/mobile-app/assets/images/logo.jpg -------------------------------------------------------------------------------- /mobile-app/assets/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ialalendd/mobile/HEAD/mobile-app/assets/images/logo.png -------------------------------------------------------------------------------- /mobile-app/lib/enums/dialog_type.dart: -------------------------------------------------------------------------------- 1 | enum DialogType { 2 | basic, 3 | buttonForm, 4 | deleteAccount 5 | } 6 | -------------------------------------------------------------------------------- /mobile-app/android/fastlane/Appfile: -------------------------------------------------------------------------------- 1 | json_key_file("play-store-credentials.json") 2 | package_name("org.freecodecamp") 3 | -------------------------------------------------------------------------------- /mobile-app/assets/fonts/Inter-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ialalendd/mobile/HEAD/mobile-app/assets/fonts/Inter-Bold.ttf -------------------------------------------------------------------------------- /mobile-app/assets/images/app-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ialalendd/mobile/HEAD/mobile-app/assets/images/app-logo.png -------------------------------------------------------------------------------- /mobile-app/android/gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Xmx1536M 2 | android.useAndroidX=true 3 | android.enableJetifier=true 4 | -------------------------------------------------------------------------------- /mobile-app/assets/fonts/Lato-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ialalendd/mobile/HEAD/mobile-app/assets/fonts/Lato-Regular.ttf -------------------------------------------------------------------------------- /mobile-app/assets/images/apple-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ialalendd/mobile/HEAD/mobile-app/assets/images/apple-logo.png -------------------------------------------------------------------------------- /mobile-app/assets/images/github-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ialalendd/mobile/HEAD/mobile-app/assets/images/github-logo.png -------------------------------------------------------------------------------- /mobile-app/assets/images/google-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ialalendd/mobile/HEAD/mobile-app/assets/images/google-logo.png -------------------------------------------------------------------------------- /mobile-app/assets/fonts/Inter-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ialalendd/mobile/HEAD/mobile-app/assets/fonts/Inter-Regular.ttf -------------------------------------------------------------------------------- /mobile-app/assets/fonts/RobotoMono-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ialalendd/mobile/HEAD/mobile-app/assets/fonts/RobotoMono-Bold.ttf -------------------------------------------------------------------------------- /mobile-app/assets/images/splash_screen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ialalendd/mobile/HEAD/mobile-app/assets/images/splash_screen.png -------------------------------------------------------------------------------- /mobile-app/assets/images/episode_default.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ialalendd/mobile/HEAD/mobile-app/assets/images/episode_default.jpg -------------------------------------------------------------------------------- /mobile-app/assets/database/bookmarked-article.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ialalendd/mobile/HEAD/mobile-app/assets/database/bookmarked-article.db -------------------------------------------------------------------------------- /mobile-app/assets/fonts/RobotoMono-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ialalendd/mobile/HEAD/mobile-app/assets/fonts/RobotoMono-Regular.ttf -------------------------------------------------------------------------------- /mobile-app/assets/images/freecodecamp-banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ialalendd/mobile/HEAD/mobile-app/assets/images/freecodecamp-banner.png -------------------------------------------------------------------------------- /mobile-app/assets/images/placeholder-profile-img.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ialalendd/mobile/HEAD/mobile-app/assets/images/placeholder-profile-img.png -------------------------------------------------------------------------------- /mobile-app/ios/Flutter/Debug.xcconfig: -------------------------------------------------------------------------------- 1 | #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" 2 | #include "Generated.xcconfig" 3 | -------------------------------------------------------------------------------- /mobile-app/ios/Flutter/Release.xcconfig: -------------------------------------------------------------------------------- 1 | #include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" 2 | #include "Generated.xcconfig" 3 | -------------------------------------------------------------------------------- /mobile-app/l10n.yaml: -------------------------------------------------------------------------------- 1 | arb-dir: lib/l10n 2 | template-arb-file: app_en.arb 3 | output-localization-file: app_localizations.dart 4 | nullable-getter: false 5 | -------------------------------------------------------------------------------- /mobile-app/lib/ui/views/podcast/episode/episode_viewmodel.dart: -------------------------------------------------------------------------------- 1 | import 'package:stacked/stacked.dart'; 2 | 3 | class EpisodeViewModel extends BaseViewModel {} 4 | -------------------------------------------------------------------------------- /mobile-app/lib/ui/views/news/news-image-viewer/news_image_viewmodel.dart: -------------------------------------------------------------------------------- 1 | import 'package:stacked/stacked.dart'; 2 | 3 | class NewsImageModel extends BaseViewModel {} 4 | -------------------------------------------------------------------------------- /mobile-app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ialalendd/mobile/HEAD/mobile-app/android/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /mobile-app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ialalendd/mobile/HEAD/mobile-app/android/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /mobile-api/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ['@babel/preset-env', {targets: {node: 'current'}}], 4 | '@babel/preset-typescript', 5 | ], 6 | 7 | }; -------------------------------------------------------------------------------- /mobile-app/android/app/src/main/res/mipmap-hdpi/launcher_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ialalendd/mobile/HEAD/mobile-app/android/app/src/main/res/mipmap-hdpi/launcher_icon.png -------------------------------------------------------------------------------- /mobile-app/android/app/src/main/res/mipmap-mdpi/launcher_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ialalendd/mobile/HEAD/mobile-app/android/app/src/main/res/mipmap-mdpi/launcher_icon.png -------------------------------------------------------------------------------- /mobile-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ialalendd/mobile/HEAD/mobile-app/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /mobile-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ialalendd/mobile/HEAD/mobile-app/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /mobile-app/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ialalendd/mobile/HEAD/mobile-app/android/app/src/main/res/mipmap-xhdpi/launcher_icon.png -------------------------------------------------------------------------------- /mobile-app/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ialalendd/mobile/HEAD/mobile-app/android/app/src/main/res/mipmap-xxhdpi/launcher_icon.png -------------------------------------------------------------------------------- /mobile-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ialalendd/mobile/HEAD/mobile-app/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /mobile-app/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ialalendd/mobile/HEAD/mobile-app/android/app/src/main/res/mipmap-xxxhdpi/launcher_icon.png -------------------------------------------------------------------------------- /mobile-app/lib/ui/views/learn/widgets/dynamic_panel/panels/hint/hint_widget_model.dart: -------------------------------------------------------------------------------- 1 | import 'package:stacked/stacked.dart'; 2 | 3 | class HintWidgetModel extends BaseViewModel {} 4 | -------------------------------------------------------------------------------- /mobile-app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ialalendd/mobile/HEAD/mobile-app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png -------------------------------------------------------------------------------- /mobile-app/lib/ui/views/learn/widgets/dynamic_panel/panels/description/description_widget_model.dart: -------------------------------------------------------------------------------- 1 | import 'package:stacked/stacked.dart'; 2 | 3 | class DescriptionModel extends BaseViewModel {} 4 | -------------------------------------------------------------------------------- /mobile-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ialalendd/mobile/HEAD/mobile-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png -------------------------------------------------------------------------------- /mobile-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ialalendd/mobile/HEAD/mobile-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png -------------------------------------------------------------------------------- /mobile-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ialalendd/mobile/HEAD/mobile-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png -------------------------------------------------------------------------------- /mobile-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ialalendd/mobile/HEAD/mobile-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png -------------------------------------------------------------------------------- /mobile-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ialalendd/mobile/HEAD/mobile-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png -------------------------------------------------------------------------------- /mobile-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ialalendd/mobile/HEAD/mobile-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png -------------------------------------------------------------------------------- /mobile-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ialalendd/mobile/HEAD/mobile-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png -------------------------------------------------------------------------------- /mobile-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ialalendd/mobile/HEAD/mobile-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png -------------------------------------------------------------------------------- /mobile-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ialalendd/mobile/HEAD/mobile-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png -------------------------------------------------------------------------------- /mobile-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ialalendd/mobile/HEAD/mobile-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png -------------------------------------------------------------------------------- /mobile-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ialalendd/mobile/HEAD/mobile-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png -------------------------------------------------------------------------------- /mobile-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ialalendd/mobile/HEAD/mobile-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png -------------------------------------------------------------------------------- /mobile-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ialalendd/mobile/HEAD/mobile-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png -------------------------------------------------------------------------------- /mobile-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ialalendd/mobile/HEAD/mobile-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png -------------------------------------------------------------------------------- /mobile-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ialalendd/mobile/HEAD/mobile-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png -------------------------------------------------------------------------------- /mobile-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ialalendd/mobile/HEAD/mobile-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png -------------------------------------------------------------------------------- /mobile-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ialalendd/mobile/HEAD/mobile-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png -------------------------------------------------------------------------------- /mobile-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ialalendd/mobile/HEAD/mobile-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png -------------------------------------------------------------------------------- /mobile-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ialalendd/mobile/HEAD/mobile-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png -------------------------------------------------------------------------------- /mobile-app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ialalendd/mobile/HEAD/mobile-app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png -------------------------------------------------------------------------------- /mobile-app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ialalendd/mobile/HEAD/mobile-app/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png -------------------------------------------------------------------------------- /mobile-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ialalendd/mobile/HEAD/mobile-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png -------------------------------------------------------------------------------- /mobile-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ialalendd/mobile/HEAD/mobile-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png -------------------------------------------------------------------------------- /mobile-app/android/app/src/main/kotlin/com/example/freecodecamp/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.example.freecodecamp 2 | 3 | import io.flutter.embedding.android.FlutterActivity 4 | 5 | class MainActivity: FlutterActivity() 6 | -------------------------------------------------------------------------------- /mobile-app/devtools_options.yaml: -------------------------------------------------------------------------------- 1 | description: This file stores settings for Dart & Flutter DevTools. 2 | documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states 3 | extensions: 4 | -------------------------------------------------------------------------------- /mobile-api/jest.config.js: -------------------------------------------------------------------------------- 1 | const {defaults} = require('jest-config'); 2 | module.exports = { 3 | transform: { 4 | "^.+\\.(ts|tsx)$": "ts-jest", 5 | "^.+\\.(js)$": "babel-jest", 6 | }, 7 | transformIgnorePatterns: [ 8 | ], 9 | }; -------------------------------------------------------------------------------- /mobile-app/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /mobile-app/lib/ui/views/learn/utils/learn_globals.dart: -------------------------------------------------------------------------------- 1 | library global_learn; 2 | 3 | // Changing values in this file will require a hot restart to take effect 4 | 5 | List hasNoCert = ['the-odin-project', 'college-algebra-with-python']; 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /mobile-app/lib/extensions/i18n_extension.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/widgets.dart'; 2 | import 'package:flutter_gen/gen_l10n/app_localizations.dart'; 3 | 4 | extension I18nContext on BuildContext { 5 | AppLocalizations get t => AppLocalizations.of(this); 6 | } 7 | -------------------------------------------------------------------------------- /mobile-app/sample.env: -------------------------------------------------------------------------------- 1 | 2 | ###NEWS### 3 | 4 | HASHNODE_PUBLICATION_ID= 5 | 6 | ALGOLIAAPPID= 7 | ALGOLIAKEY= 8 | 9 | ##AUTH## 10 | 11 | AUTH0_DOMAIN= 12 | AUTH0_CLIENT_ID= 13 | 14 | ##MISC## 15 | 16 | DEVELOPMENTMODE = TRUE 17 | SHOWALLSB = FALSE 18 | -------------------------------------------------------------------------------- /mobile-app/lib/utils/upgrade_controller.dart: -------------------------------------------------------------------------------- 1 | import 'package:freecodecamp/service/firebase/remote_config_service.dart'; 2 | import 'package:upgrader/upgrader.dart'; 3 | 4 | var upgraderController = Upgrader( 5 | minAppVersion: RemoteConfigService.remoteConfig.getString('min_app_version'), 6 | ); 7 | -------------------------------------------------------------------------------- /mobile-app/lib/service/developer_service.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_dotenv/flutter_dotenv.dart'; 2 | 3 | class DeveloperService { 4 | Future developmentMode() async { 5 | await dotenv.load(); 6 | return dotenv.get('DEVELOPMENTMODE', fallback: '').toLowerCase() == 'true'; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /mobile-app/android/gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Fri Jun 23 08:50:38 CEST 2017 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.3-all.zip 7 | -------------------------------------------------------------------------------- /mobile-app/flutter_launcher_icons.yaml: -------------------------------------------------------------------------------- 1 | # flutter pub run flutter_launcher_icons 2 | flutter_launcher_icons: 3 | image_path: "assets/images/app-logo.png" 4 | 5 | android: "launcher_icon" 6 | min_sdk_android: 21 # android min sdk min:16, default 21 7 | 8 | ios: true 9 | remove_alpha_channel_ios: true 10 | -------------------------------------------------------------------------------- /mobile-app/lib/ui/views/news/news-view-handler/news_view_handler_viewmodel.dart: -------------------------------------------------------------------------------- 1 | import 'package:stacked/stacked.dart'; 2 | 3 | class NewsViewHandlerViewModel extends BaseViewModel { 4 | int index = 1; 5 | 6 | void onTapped(int index) { 7 | this.index = index; 8 | notifyListeners(); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /mobile-api/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | api: 4 | image: '${ACR_NAME}.azurecr.io/${API_ENVIRONMENT}/mobile-api:${API_VERSION}' 5 | environment: 6 | - NODE_ENV=production 7 | - MONGODB_URL=${MONGODB_URL} 8 | ports: 9 | - '3000:3000' 10 | restart: unless-stopped 11 | -------------------------------------------------------------------------------- /mobile-app/ios/Runner.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /mobile-app/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreviewsEnabled 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /mobile-app/ios/firebase_app_id_file.json: -------------------------------------------------------------------------------- 1 | { 2 | "file_generated_by": "FlutterFire CLI", 3 | "purpose": "FirebaseAppID & ProjectID for this Firebase app in this directory", 4 | "GOOGLE_APP_ID": "1:363152234407:ios:17273b86fa3ff2e4738882", 5 | "FIREBASE_PROJECT_ID": "mobile-4ee8a", 6 | "GCM_SENDER_ID": "363152234407" 7 | } -------------------------------------------------------------------------------- /mobile-app/lib/constants/radio_articles.dart: -------------------------------------------------------------------------------- 1 | List radioArticles = [ 2 | '66bb9216add24ba427325101', 3 | '66b90438941d2f900bad52b1', 4 | '66b8d2d40c9c1d363b7c4213', 5 | '66b8d5cfd482a18d3e02825a', 6 | '66b8d2d957c651c38343a959', 7 | '66b8d2d7064c610cf26d29ae', 8 | '66b8d46af8e5d39507c4c100', 9 | ]; 10 | -------------------------------------------------------------------------------- /mobile-app/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /mobile-app/android/.gitignore: -------------------------------------------------------------------------------- 1 | gradle-wrapper.jar 2 | /.gradle 3 | /captures/ 4 | /gradlew 5 | /gradlew.bat 6 | /local.properties 7 | GeneratedPluginRegistrant.java 8 | 9 | # Remember to never publicly share your keystore. 10 | # See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app 11 | key.properties 12 | -------------------------------------------------------------------------------- /mobile-app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PreviewsEnabled 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /mobile-app/assets/sql/3_reset_episodes_schema.sql: -------------------------------------------------------------------------------- 1 | DROP TABLE episodes; 2 | 3 | CREATE TABLE episodes( 4 | id TEXT, 5 | podcastId TEXT, 6 | title TEXT, 7 | description TEXT, 8 | publicationDate TEXT, 9 | contentUrl TEXT, 10 | duration TEXT, 11 | FOREIGN KEY(podcastId) REFERENCES podcasts(id), 12 | PRIMARY KEY (id, podcastId) 13 | ); -------------------------------------------------------------------------------- /mobile-app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /mobile-app/.metadata: -------------------------------------------------------------------------------- 1 | # This file tracks properties of this Flutter project. 2 | # Used by Flutter tool to assess capabilities and perform upgrades etc. 3 | # 4 | # This file should be version controlled and should not be manually edited. 5 | 6 | version: 7 | revision: 5d36f2e7f5387b6c751449258ade8e4e6edf99be 8 | channel: beta 9 | 10 | project_type: app 11 | -------------------------------------------------------------------------------- /mobile-app/lib/enums/ext_type.dart: -------------------------------------------------------------------------------- 1 | enum Ext { js, html, css, jsx } 2 | 3 | Ext parseExt(String ext) { 4 | switch (ext) { 5 | case 'js': 6 | return Ext.js; 7 | case 'html': 8 | return Ext.html; 9 | case 'css': 10 | return Ext.css; 11 | case 'jsx': 12 | return Ext.jsx; 13 | default: 14 | return Ext.html; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /mobile-app/lib/models/learn/motivational_quote_model.dart: -------------------------------------------------------------------------------- 1 | class MotivationalQuote { 2 | final String quote; 3 | final String author; 4 | 5 | MotivationalQuote({required this.quote, required this.author}); 6 | 7 | factory MotivationalQuote.fromJson(Map data) { 8 | return MotivationalQuote(quote: data['quote'], author: data['author']); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /mobile-app/lib/ui/views/profile/profile_viewmodel.dart: -------------------------------------------------------------------------------- 1 | import 'package:freecodecamp/app/app.locator.dart'; 2 | import 'package:freecodecamp/service/authentication/authentication_service.dart'; 3 | import 'package:stacked/stacked.dart'; 4 | 5 | // import 'dart:developer'; 6 | class ProfileViewModel extends BaseViewModel { 7 | final AuthenticationService auth = locator(); 8 | } 9 | -------------------------------------------------------------------------------- /mobile-app/android/app/src/debug/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /mobile-app/android/app/src/profile/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /mobile-app/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md: -------------------------------------------------------------------------------- 1 | # Launch Screen Assets 2 | 3 | You can customize the launch screen with your own desired assets by replacing the image files in this directory. 4 | 5 | You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images. -------------------------------------------------------------------------------- /mobile-app/android/build.gradle: -------------------------------------------------------------------------------- 1 | allprojects { 2 | repositories { 3 | google() 4 | mavenCentral() 5 | } 6 | } 7 | 8 | rootProject.buildDir = '../build' 9 | subprojects { 10 | project.buildDir = "${rootProject.buildDir}/${project.name}" 11 | } 12 | subprojects { 13 | project.evaluationDependsOn(':app') 14 | } 15 | 16 | tasks.register("clean", Delete) { 17 | delete rootProject.buildDir 18 | } 19 | -------------------------------------------------------------------------------- /mobile-api/src/podcast-feed-urls.json: -------------------------------------------------------------------------------- 1 | { 2 | "feedUrls": [ 3 | "https://feed.syntax.fm/rss", 4 | "https://changelog.com/podcast/feed", 5 | "https://freecodecamp.libsyn.com/rss", 6 | "http://feeds.codenewbie.org/cnpodcast.xml", 7 | "https://feeds.transistor.fm/scrimba", 8 | "https://anchor.fm/s/ff0092f4/podcast/rss", 9 | "https://anchor.fm/s/ff054de4/podcast/rss", 10 | "https://anchor.fm/s/ff026c00/podcast/rss" 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /mobile-api/src/db-connect.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | async function dbConnect() { 4 | try { 5 | if (typeof process.env.MONGODB_URL == 'undefined') 6 | throw Error('MONGODB_URL is not defined'); 7 | await mongoose.connect(process.env.MONGODB_URL); 8 | return console.log('Database connected'); 9 | } catch (error) { 10 | console.log('Database connection error: ', error); 11 | process.exit(1); 12 | } 13 | } 14 | 15 | export default dbConnect; 16 | -------------------------------------------------------------------------------- /mobile-api/docker-compose.dev.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | mongodb: 4 | image: mongo:7.0 5 | restart: always 6 | volumes: 7 | - 'mongodb:/data/db' 8 | api: 9 | build: 10 | context: . 11 | dockerfile: Dockerfile 12 | depends_on: 13 | - mongodb 14 | environment: 15 | - MONGODB_URL=mongodb://mongodb:27017/mobile-api 16 | - NODE_ENV=production 17 | ports: 18 | - '3000:3000' 19 | restart: on-failure 20 | volumes: 21 | mongodb: 22 | -------------------------------------------------------------------------------- /mobile-api/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | parserOptions: { 5 | tsconfigRootDir: __dirname, 6 | project: ['./tsconfig.json'], 7 | }, 8 | plugins: ['@typescript-eslint'], 9 | extends: [ 10 | 'eslint:recommended', 11 | 'plugin:@typescript-eslint/recommended', 12 | 'plugin:@typescript-eslint/recommended-requiring-type-checking', 13 | 'prettier', 14 | ], 15 | ignorePatterns: ['.eslintrc.js', '*.config.js'], 16 | }; 17 | -------------------------------------------------------------------------------- /mobile-api/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine AS builder 2 | WORKDIR /build 3 | COPY . . 4 | RUN npm ci 5 | RUN npm run build 6 | 7 | FROM node:20-alpine AS dependencies 8 | WORKDIR /build 9 | COPY package-lock.json package.json ./ 10 | RUN npm ci --production 11 | 12 | FROM node:20-alpine 13 | WORKDIR /api 14 | COPY --from=builder /build/package.json /build/package-lock.json ./ 15 | COPY --from=builder /build/prod/ ./prod/ 16 | COPY --from=dependencies /build/node_modules ./node_modules/ 17 | CMD ["npm", "start"] 18 | -------------------------------------------------------------------------------- /mobile-app/lib/enums/theme_type.dart: -------------------------------------------------------------------------------- 1 | // default can't be used as a value for an enum so suffixing with Theme 2 | enum Themes { nightTheme, defaultTheme } 3 | 4 | parseThemes(String theme) { 5 | switch (theme) { 6 | case 'night': 7 | return Themes.nightTheme; 8 | case 'default': 9 | return Themes.defaultTheme; 10 | default: 11 | return Themes.defaultTheme; 12 | } 13 | } 14 | 15 | extension ThemesValue on Themes { 16 | String get value => name.replaceFirst('Theme', ''); 17 | } 18 | -------------------------------------------------------------------------------- /mobile-app/android/app/src/main/res/drawable/launch_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 12 | 13 | -------------------------------------------------------------------------------- /mobile-app/android/app/src/main/res/drawable-v21/launch_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 12 | 13 | -------------------------------------------------------------------------------- /mobile-app/firebase.json: -------------------------------------------------------------------------------- 1 | {"flutter":{"platforms":{"android":{"default":{"projectId":"mobile-4ee8a","appId":"1:363152234407:android:6293f9873ae6df8a738882","fileOutput":"android/app/google-services.json"}},"ios":{"default":{"projectId":"mobile-4ee8a","appId":"1:363152234407:ios:17273b86fa3ff2e4738882","uploadDebugSymbols":true,"fileOutput":"ios/Runner/GoogleService-Info.plist"}},"dart":{"lib/firebase_options.dart":{"projectId":"mobile-4ee8a","configurations":{"android":"1:363152234407:android:6293f9873ae6df8a738882","ios":"1:363152234407:ios:17273b86fa3ff2e4738882"}}}}}} 2 | -------------------------------------------------------------------------------- /mobile-app/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "filename" : "LaunchImage.png", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "filename" : "LaunchImage@2x.png", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "idiom" : "universal", 15 | "filename" : "LaunchImage@3x.png", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "version" : 1, 21 | "author" : "xcode" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /mobile-app/assets/sql/1_create_db_schema.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE podcasts( 2 | id TEXT PRIMARY KEY, 3 | url TEXT, 4 | link TEXT, 5 | title TEXT, 6 | description TEXT, 7 | image TEXT, 8 | copyright TEXT, 9 | numEps INTEGER 10 | ); 11 | 12 | CREATE TABLE episodes( 13 | guid TEXT, 14 | podcastId TEXT, 15 | title TEXT, 16 | description TEXT, 17 | link TEXT, 18 | publicationDate INTEGER, 19 | contentUrl TEXT, 20 | imageUrl TEXT, 21 | duration INTEGER, 22 | downloaded INTEGER, 23 | FOREIGN KEY(podcastId) REFERENCES podcasts(id), 24 | PRIMARY KEY (guid, podcastId) 25 | ); -------------------------------------------------------------------------------- /mobile-app/lib/models/learn/saved_challenge_model.dart: -------------------------------------------------------------------------------- 1 | import 'package:freecodecamp/models/learn/challenge_model.dart'; 2 | 3 | class SavedChallenge { 4 | final String id; 5 | final List files; 6 | 7 | SavedChallenge({ 8 | required this.id, 9 | required this.files, 10 | }); 11 | 12 | factory SavedChallenge.fromJson(Map data) { 13 | return SavedChallenge( 14 | id: data['id'], 15 | files: (data['files'] as List) 16 | .map((file) => ChallengeFile.fromJson(file)) 17 | .toList(), 18 | ); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /mobile-app/ios/Runner/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | import Flutter 3 | 4 | @main 5 | @objc class AppDelegate: FlutterAppDelegate { 6 | override func application( 7 | _ application: UIApplication, 8 | didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? 9 | ) -> Bool { 10 | if #available(iOS 10.0, *) { 11 | UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate 12 | } 13 | 14 | GeneratedPluginRegistrant.register(with: self) 15 | return super.application(application, didFinishLaunchingWithOptions: launchOptions) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /mobile-app/lib/service/firebase/analytics_service.dart: -------------------------------------------------------------------------------- 1 | import 'package:firebase_analytics/firebase_analytics.dart'; 2 | import 'package:freecodecamp/service/firebase/analytics_observer.dart'; 3 | 4 | class AnalyticsService { 5 | static final AnalyticsService _instance = AnalyticsService._internal(); 6 | 7 | static final FirebaseAnalytics _analytics = FirebaseAnalytics.instance; 8 | 9 | factory AnalyticsService() { 10 | return _instance; 11 | } 12 | 13 | AnalyticsObserver getAnalyticsObserver() { 14 | return AnalyticsObserver(analytics: _analytics); 15 | } 16 | 17 | AnalyticsService._internal(); 18 | } 19 | -------------------------------------------------------------------------------- /mobile-app/ios/.gitignore: -------------------------------------------------------------------------------- 1 | *.mode1v3 2 | *.mode2v3 3 | *.moved-aside 4 | *.pbxuser 5 | *.perspectivev3 6 | **/*sync/ 7 | .sconsign.dblite 8 | .tags* 9 | **/.vagrant/ 10 | **/DerivedData/ 11 | Icon? 12 | **/Pods/ 13 | **/.symlinks/ 14 | profile 15 | xcuserdata 16 | **/.generated/ 17 | Flutter/App.framework 18 | Flutter/Flutter.framework 19 | Flutter/Flutter.podspec 20 | Flutter/Generated.xcconfig 21 | Flutter/app.flx 22 | Flutter/app.zip 23 | Flutter/flutter_assets/ 24 | Flutter/flutter_export_environment.sh 25 | ServiceDefinitions.json 26 | Runner/GeneratedPluginRegistrant.* 27 | 28 | # Exceptions to above rules. 29 | !default.mode1v3 30 | !default.mode2v3 31 | !default.pbxuser 32 | !default.perspectivev3 33 | -------------------------------------------------------------------------------- /mobile-app/lib/models/main/portfolio_model.dart: -------------------------------------------------------------------------------- 1 | class Portfolio { 2 | final String id; 3 | // Below properties may be empty string instead of null, CONFIRM 4 | final String? title; 5 | final String? url; 6 | final String? image; 7 | final String? description; 8 | 9 | Portfolio({ 10 | required this.id, 11 | this.title, 12 | this.url, 13 | this.image, 14 | this.description, 15 | }); 16 | 17 | factory Portfolio.fromJson(Map data) { 18 | return Portfolio( 19 | id: data['id'], 20 | title: data['title'], 21 | url: data['url'], 22 | image: data['image'], 23 | description: data['description']); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /mobile-app/analysis_options.yaml: -------------------------------------------------------------------------------- 1 | include: package:flutter_lints/flutter.yaml 2 | 3 | analyzer: 4 | exclude: [ 5 | temp/**, 6 | lib/app/app.dart, 7 | lib/app/app.locator.dart, 8 | lib/app/app.logger.dart, 9 | lib/app/app.router.dart 10 | ] 11 | errors: 12 | todo: warning 13 | linter: 14 | rules: 15 | avoid_print: false 16 | prefer_single_quotes: true 17 | use_build_context_synchronously: false 18 | always_use_package_imports: true 19 | avoid_relative_lib_imports: true 20 | directives_ordering: true 21 | sort_pub_dependencies: true 22 | 23 | # Additional information about this file can be found at 24 | # https://dart.dev/guides/language/analysis-options 25 | -------------------------------------------------------------------------------- /mobile-app/lib/models/news/bookmarked_tutorial_model.dart: -------------------------------------------------------------------------------- 1 | class BookmarkedTutorial { 2 | late int bookmarkId; 3 | late String tutorialTitle; 4 | late String id; 5 | late String tutorialText; 6 | late String authorName; 7 | 8 | BookmarkedTutorial.fromMap(Map map) { 9 | bookmarkId = map['bookmark_id']; 10 | tutorialTitle = map['articleTitle']; 11 | id = map['articleId']; 12 | tutorialText = map['articleText']; 13 | authorName = map['authorName']; 14 | } 15 | 16 | BookmarkedTutorial({ 17 | required this.bookmarkId, 18 | required this.tutorialTitle, 19 | required this.id, 20 | required this.tutorialText, 21 | required this.authorName, 22 | }); 23 | } 24 | -------------------------------------------------------------------------------- /mobile-app/lib/ui/views/news/news-author/news_author_viewmodel.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter_dotenv/flutter_dotenv.dart'; 2 | import 'package:freecodecamp/app/app.locator.dart'; 3 | import 'package:freecodecamp/models/news/tutorial_model.dart'; 4 | import 'package:freecodecamp/service/news/api_service.dart'; 5 | import 'package:stacked/stacked.dart'; 6 | 7 | class NewsAuthorViewModel extends BaseViewModel { 8 | final _newsApiService = locator(); 9 | 10 | Future fetchAuthor(String authorSlug) async { 11 | await dotenv.load(fileName: '.env'); 12 | 13 | final authorData = await _newsApiService.getAuthor(authorSlug); 14 | 15 | return Author.toAuthorFromJson(authorData); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for the mobile app 4 | title: '' 5 | labels: 'type: feature request' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /mobile-app/android/fastlane/Fastfile: -------------------------------------------------------------------------------- 1 | # This file contains the fastlane.tools configuration 2 | # You can find the documentation at https://docs.fastlane.tools 3 | # 4 | # For a list of all available actions, check out 5 | # 6 | # https://docs.fastlane.tools/actions 7 | # 8 | # For a list of all available plugins, check out 9 | # 10 | # https://docs.fastlane.tools/plugins/available-plugins 11 | # 12 | 13 | # Uncomment the line if you want fastlane to automatically update itself 14 | # update_fastlane 15 | 16 | default_platform(:android) 17 | 18 | platform :android do 19 | desc "Uploads appbundle to internal testing track of Google Play" 20 | lane :deploy do 21 | upload_to_play_store( 22 | track: 'internal', 23 | aab: '../build/app/outputs/bundle/release/app-release.aab', 24 | ) 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /mobile-app/lib/service/dio_service.dart: -------------------------------------------------------------------------------- 1 | import 'package:curl_logger_dio_interceptor/curl_logger_dio_interceptor.dart'; 2 | import 'package:dio/dio.dart'; 3 | import 'package:flutter_dotenv/flutter_dotenv.dart'; 4 | import 'package:pretty_dio_logger/pretty_dio_logger.dart'; 5 | 6 | class DioService { 7 | static final DioService _dioService = DioService._internal(); 8 | 9 | static final Dio dio = Dio(); 10 | 11 | factory DioService() { 12 | return _dioService; 13 | } 14 | 15 | Future init() async { 16 | await dotenv.load(); 17 | bool isDevMode = 18 | dotenv.get('DEVELOPMENTMODE', fallback: '').toLowerCase() == 'true'; 19 | 20 | if (isDevMode) { 21 | dio.interceptors.add(PrettyDioLogger(responseBody: false)); 22 | dio.interceptors.add(CurlLoggerDioInterceptor()); 23 | } 24 | } 25 | 26 | DioService._internal(); 27 | } 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve the app 4 | title: '' 5 | labels: 'type: bug' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Smartphone (please complete the following information):** 27 | - Device: [e.g. iPhone6] 28 | - OS: [e.g. iOS8.1] 29 | - Browser [e.g. stock browser, safari] 30 | - Version [e.g. 22] 31 | 32 | **Additional context** 33 | Add any other context about the problem here. 34 | -------------------------------------------------------------------------------- /mobile-app/ios/Flutter/AppFrameworkInfo.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | en 7 | CFBundleExecutable 8 | App 9 | CFBundleIdentifier 10 | io.flutter.flutter.app 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | App 15 | CFBundlePackageType 16 | FMWK 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleSignature 20 | ???? 21 | CFBundleVersion 22 | 1.0 23 | MinimumOSVersion 24 | 12.0 25 | 26 | 27 | -------------------------------------------------------------------------------- /mobile-app/android/fastlane/README.md: -------------------------------------------------------------------------------- 1 | fastlane documentation 2 | ---- 3 | 4 | # Installation 5 | 6 | Make sure you have the latest version of the Xcode command line tools installed: 7 | 8 | ```sh 9 | xcode-select --install 10 | ``` 11 | 12 | For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane) 13 | 14 | # Available Actions 15 | 16 | ## Android 17 | 18 | ### android deploy 19 | 20 | ```sh 21 | [bundle exec] fastlane android deploy 22 | ``` 23 | 24 | Uploads appbundle to internal testing track of Google Play 25 | 26 | ---- 27 | 28 | This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run. 29 | 30 | More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools). 31 | 32 | The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools). 33 | -------------------------------------------------------------------------------- /mobile-app/lib/ui/views/learn/challenge/templates/python/python_viewmodel.dart: -------------------------------------------------------------------------------- 1 | import 'package:freecodecamp/app/app.locator.dart'; 2 | import 'package:freecodecamp/models/learn/challenge_model.dart'; 3 | import 'package:freecodecamp/service/learn/learn_service.dart'; 4 | import 'package:stacked/stacked.dart'; 5 | 6 | class PythonViewModel extends BaseViewModel { 7 | int _currentChoice = -1; 8 | int get currentChoice => _currentChoice; 9 | 10 | bool? _choiceStatus; 11 | bool? get choiceStatus => _choiceStatus; 12 | 13 | final LearnService learnService = locator(); 14 | 15 | set setCurrentChoice(int choice) { 16 | _currentChoice = choice; 17 | notifyListeners(); 18 | } 19 | 20 | set setChoiceStatus(bool? status) { 21 | _choiceStatus = status; 22 | notifyListeners(); 23 | } 24 | 25 | void checkOption(Challenge challenge) async { 26 | bool isCorrect = challenge.question!.solution - 1 == currentChoice; 27 | setChoiceStatus = isCorrect; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /mobile-api/src/schema-keys.json: -------------------------------------------------------------------------------- 1 | {"id":"5bd30e0f1caf6ac3ddddddb5","email":"foo@bar.com","emailVerified":true,"isBanned":false,"isCheater":false,"acceptedPrivacyTerms":false,"sendQuincyEmail":false,"username":"username","about":"about","name":"name","picture":"picture","currentChallengeId":"currentChallengeId","isHonest":false,"isFrontEndCert":false,"isDataVisCert":false,"isBackEndCert":false,"isFullStackCert":false,"isRespWebDesignCert":false,"is2018DataVisCert":false,"isFrontEndLibsCert":false,"isJsAlgoDataStructCert":false,"isApisMicroservicesCert":false,"isInfosecQaCert":false,"isQaCertV7":false,"isInfosecCertV7":false,"is2018FullStackCert":false,"isSciCompPyCertV7":false,"isDataAnalysisPyCertV7":false,"isMachineLearningPyCertV7":false,"isRelationalDatabaseCertV8":false,"profileUI":{"isLocked":false,"showAbout":false,"showCerts":false,"showDonation":false,"showHeatMap":false,"showLocation":false,"showName":false,"showPoints":false,"showPortfolio":false,"showTimeLine":false},"isDonating":false,"badges":[],"progressTimestamps":[]} -------------------------------------------------------------------------------- /mobile-app/lib/ui/views/learn/widgets/console/console_viewmodel.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_inappwebview/flutter_inappwebview.dart'; 3 | import 'package:stacked/stacked.dart'; 4 | 5 | class JavaScriptConsoleViewModel extends BaseViewModel { 6 | ScrollController controller = ScrollController(); 7 | 8 | Color getConsoleTextColor(ConsoleMessageLevel messageLevel) { 9 | switch (messageLevel.toString()) { 10 | case 'ConsoleMessageLevel.DEBUG': 11 | return Colors.white.withOpacity(0.87); 12 | case 'ConsoleMessageLevel.ERROR': 13 | return Colors.red.withOpacity(0.87); 14 | case 'ConsoleMessageLevel.LOG': 15 | return Colors.white.withOpacity(0.87); 16 | case 'ConsoleMessageLevel.TIP': 17 | return Colors.blue.withOpacity(0.87); 18 | case 'ConsoleMessageLevel.WARNING': 19 | return Colors.yellow.withOpacity(0.87); 20 | default: 21 | return Colors.white.withOpacity(0.87); 22 | } 23 | } 24 | 25 | void scrollToBottom() {} 26 | } 27 | -------------------------------------------------------------------------------- /mobile-api/src/models/Episode.ts: -------------------------------------------------------------------------------- 1 | import * as mongoose from 'mongoose'; 2 | 3 | const Schema = mongoose.Schema; 4 | 5 | export interface Episode { 6 | _id: mongoose.Types.ObjectId; 7 | title: string; 8 | description: string; 9 | feedUrl: string; 10 | podcastLink: string; 11 | imageLink: string; 12 | copyright: string; 13 | numOfEps: number; 14 | } 15 | 16 | const EpisodeSchema = new Schema({ 17 | guid: { 18 | type: String, 19 | required: true, 20 | }, 21 | podcastId: { 22 | type: Schema.Types.ObjectId, 23 | ref: 'Podcast', 24 | required: true, 25 | }, 26 | title: { 27 | type: String, 28 | required: true, 29 | }, 30 | description: { 31 | type: String, 32 | default: '', 33 | }, 34 | publicationDate: { 35 | type: Date, 36 | }, 37 | audioUrl: { 38 | type: String, 39 | required: true, 40 | }, 41 | duration: { 42 | type: String, 43 | }, 44 | }); 45 | 46 | export default (mongoose.models.Episode as mongoose.Model) || 47 | mongoose.model('Episode', EpisodeSchema, 'episodes'); 48 | -------------------------------------------------------------------------------- /mobile-app/lib/models/learn/completed_challenge_model.dart: -------------------------------------------------------------------------------- 1 | import 'package:freecodecamp/models/learn/challenge_model.dart'; 2 | 3 | class CompletedChallenge { 4 | final String id; 5 | final String? solution; 6 | final String? githubLink; 7 | final int? challengeType; 8 | final DateTime completedDate; 9 | final List files; 10 | 11 | CompletedChallenge({ 12 | required this.id, 13 | this.solution, 14 | this.githubLink, 15 | this.challengeType, 16 | required this.completedDate, 17 | required this.files, 18 | }); 19 | 20 | factory CompletedChallenge.fromJson(Map data) { 21 | return CompletedChallenge( 22 | id: data['id'], 23 | solution: data['solution'], 24 | githubLink: data['githubLink'], 25 | challengeType: data['challengeType'], 26 | completedDate: DateTime.fromMillisecondsSinceEpoch(data['completedDate']), 27 | files: (data['files'] as List) 28 | .map((file) => ChallengeFile.fromJson(file)) 29 | .toList(), 30 | ); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /mobile-app/lib/enums/alert_type.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | enum Alert { 4 | warning, 5 | danger, 6 | success, 7 | } 8 | 9 | class AlertColor { 10 | const AlertColor({ 11 | required this.backgroundColor, 12 | required this.textColor, 13 | }); 14 | 15 | final Color backgroundColor; 16 | final Color textColor; 17 | } 18 | 19 | // This returns the colors for the different types of alerts 20 | 21 | AlertColor returnColor(Alert alert) { 22 | switch (alert) { 23 | case Alert.warning: 24 | return AlertColor( 25 | backgroundColor: const Color.fromRGBO(0xd9, 0xed, 0xf7, 1), 26 | textColor: Colors.blue.shade900, 27 | ); 28 | case Alert.danger: 29 | return AlertColor( 30 | backgroundColor: const Color.fromRGBO(0xf8, 0xd7, 0xda, 1), 31 | textColor: Colors.red.shade900, 32 | ); 33 | case Alert.success: 34 | return AlertColor( 35 | backgroundColor: const Color.fromRGBO(0xd4, 0xed, 0xda, 1), 36 | textColor: Colors.green.shade900, 37 | ); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /mobile-app/android/settings.gradle: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | def flutterSdkPath = { 3 | def properties = new Properties() 4 | file("local.properties").withInputStream { properties.load(it) } 5 | def flutterSdkPath = properties.getProperty("flutter.sdk") 6 | assert flutterSdkPath != null, "flutter.sdk not set in local.properties" 7 | return flutterSdkPath 8 | }() 9 | 10 | includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") 11 | 12 | repositories { 13 | google() 14 | mavenCentral() 15 | gradlePluginPortal() 16 | } 17 | } 18 | 19 | plugins { 20 | id "dev.flutter.flutter-plugin-loader" version "1.0.0" 21 | id "com.android.application" version "7.3.1" apply false 22 | id "org.jetbrains.kotlin.android" version "1.9.10" apply false 23 | // START: FlutterFire Configuration 24 | id "com.google.gms.google-services" version "4.3.15" apply false 25 | id "com.google.firebase.crashlytics" version "2.8.1" apply false 26 | // END: FlutterFire Configuration 27 | } 28 | 29 | include ":app" 30 | -------------------------------------------------------------------------------- /mobile-app/lib/ui/views/learn/widgets/open_close_icon_widget.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:freecodecamp/models/learn/curriculum_model.dart'; 3 | import 'package:freecodecamp/ui/views/learn/block/block_viewmodel.dart'; 4 | 5 | class OpenCloseIcon extends StatelessWidget { 6 | const OpenCloseIcon({ 7 | Key? key, 8 | required this.block, 9 | required this.model, 10 | }) : super(key: key); 11 | 12 | final Block block; 13 | final BlockViewModel model; 14 | 15 | @override 16 | Widget build(BuildContext context) { 17 | return Column( 18 | mainAxisAlignment: MainAxisAlignment.center, 19 | children: [ 20 | IconButton( 21 | iconSize: 35, 22 | icon: model.isOpen 23 | ? const Icon(Icons.expand_less) 24 | : const Icon(Icons.expand_more), 25 | onPressed: () async { 26 | model.setBlockOpenState( 27 | block.name, 28 | model.isOpen, 29 | ); 30 | }, 31 | ), 32 | ], 33 | ); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /mobile-api/src/models/Podcast.ts: -------------------------------------------------------------------------------- 1 | import * as mongoose from 'mongoose'; 2 | 3 | const Schema = mongoose.Schema; 4 | 5 | export interface Podcast { 6 | _id: mongoose.Types.ObjectId; 7 | title: string; 8 | description: string; 9 | feedUrl: string; 10 | podcastLink: string; 11 | imageLink: string; 12 | copyright: string; 13 | numOfEps: number; 14 | } 15 | 16 | const PodcastSchema = new Schema({ 17 | title: { 18 | type: String, 19 | required: true, 20 | }, 21 | description: { 22 | type: String, 23 | default: '', 24 | }, 25 | feedUrl: { 26 | type: String, 27 | required: true, 28 | }, 29 | podcastLink: { 30 | type: String, 31 | default: '', 32 | }, 33 | imageLink: { 34 | type: String, 35 | required: true, 36 | }, 37 | copyright: { 38 | type: String, 39 | default: '', 40 | }, 41 | numOfEps: { 42 | type: Number, 43 | required: true, 44 | default: 0, 45 | }, 46 | }); 47 | 48 | export default (mongoose.models.Podcast as mongoose.Model) || 49 | mongoose.model('Podcast', PodcastSchema, 'podcasts'); 50 | -------------------------------------------------------------------------------- /mobile-app/android/app/src/main/res/values-night/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 15 | 18 | 19 | -------------------------------------------------------------------------------- /mobile-app/lib/ui/views/news/widgets/back_to_top_button.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class BackToTopButton extends StatelessWidget { 4 | final VoidCallback onPressed; 5 | 6 | const BackToTopButton({Key? key, required this.onPressed}) : super(key: key); 7 | 8 | @override 9 | Widget build(BuildContext context) { 10 | return Align( 11 | alignment: Alignment.bottomRight, 12 | child: Padding( 13 | padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 128), 14 | child: FloatingActionButton( 15 | onPressed: onPressed, 16 | shape: RoundedRectangleBorder( 17 | side: const BorderSide( 18 | width: 1, 19 | color: Colors.white, 20 | ), 21 | borderRadius: BorderRadius.circular(100), 22 | ), 23 | backgroundColor: const Color.fromRGBO(0x2A, 0x2A, 0x40, 1), 24 | child: const Icon( 25 | Icons.keyboard_arrow_up, 26 | size: 40, 27 | color: Colors.white, 28 | ), 29 | ), 30 | ), 31 | ); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /mobile-app/test_driver/integration_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:flutter_driver/flutter_driver.dart'; 4 | import 'package:integration_test/integration_test_driver_extended.dart'; 5 | 6 | Future main() async { 7 | FlutterDriver driver = await FlutterDriver.connect(); 8 | final vm = await driver.serviceClient.getVM(); 9 | final platform = vm.operatingSystem; 10 | 11 | if (platform!.toLowerCase().contains('android')) { 12 | Process.runSync( 13 | 'adb', 14 | [ 15 | 'shell', 16 | 'pm', 17 | 'grant', 18 | 'org.freecodecamp', 19 | 'android.permission.POST_NOTIFICATIONS' 20 | ], 21 | ); 22 | } 23 | 24 | try { 25 | await integrationDriver( 26 | onScreenshot: (String screenshotName, List screenshotBytes, 27 | [Map? _]) async { 28 | final File image = await File('screenshots/$screenshotName.png') 29 | .create(recursive: true); 30 | image.writeAsBytesSync(screenshotBytes); 31 | return true; 32 | }, 33 | ); 34 | } catch (e) { 35 | print('Error occured: $e'); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /mobile-api/src/index.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | import Bree from 'bree'; 3 | import express, { Request, Response } from 'express'; 4 | import path from 'path/posix'; 5 | import dbConnect from './db-connect'; 6 | import podcastRoutes from './routes'; 7 | 8 | void (async () => { 9 | if (process.env.NODE_ENV !== 'production') { 10 | const tsWorker = (await import('@breejs/ts-worker')).default; 11 | Bree.extend(tsWorker); 12 | } 13 | })(); 14 | 15 | const app = express(); 16 | const port = 3000; 17 | const bree = new Bree({ 18 | root: path.join(__dirname, 'jobs'), 19 | defaultExtension: process.env.NODE_ENV === 'production' ? 'js' : 'ts', 20 | jobs: [ 21 | { 22 | name: 'update-podcasts', 23 | timeout: 0, 24 | interval: '30m', 25 | }, 26 | ], 27 | }); 28 | 29 | app.get('/ping', (req: Request, res: Response) => { 30 | res.json({ msg: 'pong' }); 31 | }); 32 | 33 | app.use('/podcasts', podcastRoutes); 34 | 35 | void dbConnect().then(() => { 36 | app.listen(port, () => { 37 | console.log(`API listening on port: ${port}`); 38 | console.log('Initialising jobs...'); 39 | void bree.start(); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /mobile-app/android/app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 | 16 | 19 | 20 | -------------------------------------------------------------------------------- /mobile-app/lib/ui/views/news/news-image-viewer/news_image_view.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:freecodecamp/ui/views/news/news-image-viewer/news_image_viewmodel.dart'; 5 | import 'package:photo_view/photo_view.dart'; 6 | import 'package:stacked/stacked.dart'; 7 | 8 | class NewsImageView extends StatelessWidget { 9 | const NewsImageView({ 10 | Key? key, 11 | required this.imgUrl, 12 | required this.isDataUrl, 13 | }) : super(key: key); 14 | 15 | final String imgUrl; 16 | final bool isDataUrl; 17 | 18 | @override 19 | Widget build(BuildContext context) { 20 | return ViewModelBuilder.nonReactive( 21 | viewModelBuilder: () => NewsImageModel(), 22 | builder: (context, model, child) => Scaffold( 23 | body: PhotoView( 24 | backgroundDecoration: const BoxDecoration( 25 | color: Color.fromRGBO(0x2A, 0x2A, 0x40, 1), 26 | backgroundBlendMode: BlendMode.color, 27 | ), 28 | imageProvider: isDataUrl 29 | ? MemoryImage(base64Decode(imgUrl.split(',').last)) 30 | : NetworkImage(imgUrl) as ImageProvider, 31 | ), 32 | ), 33 | ); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /mobile-app/ios/Runner/GoogleService-Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CLIENT_ID 6 | 363152234407-qb25f7aak3egr8iod13bhsltjgf8viuq.apps.googleusercontent.com 7 | REVERSED_CLIENT_ID 8 | com.googleusercontent.apps.363152234407-qb25f7aak3egr8iod13bhsltjgf8viuq 9 | API_KEY 10 | AIzaSyCFcTnIuxW7AeskmoNwbrEZX_ZvYz-aAdw 11 | GCM_SENDER_ID 12 | 363152234407 13 | PLIST_VERSION 14 | 1 15 | BUNDLE_ID 16 | org.freecodecamp.ios 17 | PROJECT_ID 18 | mobile-4ee8a 19 | STORAGE_BUCKET 20 | mobile-4ee8a.appspot.com 21 | IS_ADS_ENABLED 22 | 23 | IS_ANALYTICS_ENABLED 24 | 25 | IS_APPINVITE_ENABLED 26 | 27 | IS_GCM_ENABLED 28 | 29 | IS_SIGNIN_ENABLED 30 | 31 | GOOGLE_APP_ID 32 | 1:363152234407:ios:17273b86fa3ff2e4738882 33 | 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Backend API ### 2 | node_modules/ 3 | 4 | # Production build 5 | mobile-api/prod/ 6 | 7 | ### Flutter ### 8 | # Miscellaneous 9 | *.class 10 | *.log 11 | *.pyc 12 | *.swp 13 | .DS_Store 14 | .atom/ 15 | .buildlog/ 16 | .history 17 | .svn/ 18 | 19 | # IntelliJ related 20 | *.iml 21 | *.ipr 22 | *.iws 23 | .idea/ 24 | 25 | # The .vscode folder contains launch configuration and tasks you configure in 26 | # VS Code which you may wish to be included in version control, so this line 27 | # is commented out by default. 28 | #.vscode/ 29 | 30 | # Flutter/Dart/Pub related 31 | **/doc/api/ 32 | **/ios/Flutter/.last_build_id 33 | .dart_tool/ 34 | .flutter-plugins 35 | .flutter-plugins-dependencies 36 | .packages 37 | .pub-cache/ 38 | .pub/ 39 | build/ 40 | 41 | # Web related 42 | **/lib/generated_plugin_registrant.dart 43 | 44 | # Symbolication related 45 | app.*.symbols 46 | 47 | # Obfuscation related 48 | app.*.map.json 49 | 50 | # Android Studio will place build artifacts here 51 | **/android/app/debug 52 | **/android/app/profile 53 | **/android/app/release 54 | 55 | # VS Code 56 | .vscode/ 57 | .env* 58 | .fvm/ 59 | 60 | # Test screenshots 61 | mobile-app/screenshots/ 62 | 63 | # fastlane 64 | **/fastlane/report.xml 65 | play-store-credentials.json 66 | 67 | generated-tests/ 68 | curriculum.json 69 | -------------------------------------------------------------------------------- /mobile-app/lib/models/main/profile_ui_model.dart: -------------------------------------------------------------------------------- 1 | class ProfileUI { 2 | final bool? isLocked; 3 | final bool? showAbout; 4 | final bool? showCerts; 5 | final bool? showDonation; 6 | final bool? showHeatMap; 7 | final bool? showLocation; 8 | final bool? showName; 9 | final bool? showPoints; 10 | final bool? showPortfolio; 11 | final bool? showTimeLine; 12 | 13 | ProfileUI( 14 | {required this.isLocked, 15 | required this.showAbout, 16 | required this.showCerts, 17 | required this.showDonation, 18 | required this.showHeatMap, 19 | required this.showLocation, 20 | required this.showName, 21 | required this.showPoints, 22 | required this.showPortfolio, 23 | required this.showTimeLine}); 24 | 25 | factory ProfileUI.fromJson(Map data) { 26 | return ProfileUI( 27 | isLocked: data['isLocked'], 28 | showAbout: data['showAbout'], 29 | showCerts: data['showCerts'], 30 | showDonation: data['showDonation'], 31 | showHeatMap: data['showHeatMap'], 32 | showLocation: data['showLocation'], 33 | showName: data['showName'], 34 | showPoints: data['showPoints'], 35 | showPortfolio: data['showPortfolio'], 36 | showTimeLine: data['showTimeLine']); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /mobile-app/lib/ui/views/learn/widgets/custom_alert_widget.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:freecodecamp/enums/alert_type.dart'; 3 | 4 | class CustomAlert extends StatelessWidget { 5 | const CustomAlert({ 6 | Key? key, 7 | required this.text, 8 | required this.alertType, 9 | }) : super(key: key); 10 | 11 | final String text; 12 | final Alert alertType; 13 | 14 | @override 15 | Widget build(BuildContext context) { 16 | Size screen = MediaQuery.of(context).size; 17 | 18 | AlertColor alert = returnColor(alertType); 19 | 20 | return Container( 21 | padding: const EdgeInsets.all(8), 22 | margin: const EdgeInsets.all(8), 23 | decoration: BoxDecoration( 24 | color: alert.backgroundColor, 25 | border: Border.all( 26 | width: 2, 27 | color: alert.textColor, 28 | ), 29 | borderRadius: const BorderRadius.all( 30 | Radius.circular(5), 31 | ), 32 | ), 33 | constraints: const BoxConstraints(minHeight: 50), 34 | width: screen.width, 35 | child: Text( 36 | text, 37 | style: TextStyle( 38 | color: alert.textColor, 39 | fontWeight: FontWeight.bold, 40 | height: 1.2, 41 | ), 42 | ), 43 | ); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@freecodecamp/mobile", 3 | "version": "0.0.1", 4 | "description": "The freeCodeCamp mobile app open-source codebase", 5 | "license": "BSD-3-Clause", 6 | "private": true, 7 | "engines": { 8 | "node": ">=16", 9 | "npm": ">=8" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/freeCodeCamp/mobile.git" 14 | }, 15 | "bugs": { 16 | "url": "https://github.com/freeCodeCamp/mobile/issues" 17 | }, 18 | "homepage": "https://github.com/freeCodeCamp/mobile#readme", 19 | "author": "freeCodeCamp ", 20 | "scripts": { 21 | "build:api": "cd mobile-api && npm run build", 22 | "develop": "npm-run-all flutter:build-runner -p develop:*", 23 | "develop:api": "cd mobile-api && npm run dev", 24 | "develop:app": "cd mobile-app && flutter run", 25 | "flutter:build-runner": "cd mobile-app && flutter pub run build_runner build --delete-conflicting-outputs", 26 | "install:api": "cd mobile-api && npm ci", 27 | "install:app": "cd mobile-app && flutter pub get", 28 | "postinstall": "npm-run-all -p install:*", 29 | "prepare": "husky" 30 | }, 31 | "devDependencies": { 32 | "husky": "9.0.11", 33 | "lint-staged": "15.2.7", 34 | "npm-run-all2": "6.2.0", 35 | "shx": "0.3.4" 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /.github/workflows/api-build-and-test.yml: -------------------------------------------------------------------------------- 1 | name: Build and Test API 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | defaults: 10 | run: 11 | working-directory: mobile-api 12 | 13 | jobs: 14 | build-and-test: 15 | name: Build and Test API 16 | runs-on: ubuntu-latest 17 | 18 | strategy: 19 | matrix: 20 | node-version: [20.x] 21 | 22 | steps: 23 | - name: Checkout source code 24 | uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4 25 | 26 | - name: Use Node.js ${{ matrix.node-version }} 27 | uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4 28 | with: 29 | node-version: ${{ matrix.node-version }} 30 | cache: "npm" 31 | 32 | - name: Install dependencies 33 | run: npm ci 34 | 35 | - name: Lint source files 36 | run: npm run lint 37 | 38 | - name: Build and start container 39 | run: docker compose -f docker-compose.dev.yml up -d 40 | 41 | - name: Sleep for 1 minute 42 | run: sleep 60 43 | 44 | - name: Ping container 45 | run: curl localhost:3000/ping 46 | 47 | - name: Create .env file 48 | run: cp sample.env .env 49 | 50 | - name: Run tests 51 | run: npm test 52 | -------------------------------------------------------------------------------- /mobile-app/lib/ui/views/learn/widgets/audio/audio_player_viewmodel.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:audio_service/audio_service.dart'; 4 | import 'package:freecodecamp/app/app.locator.dart'; 5 | import 'package:freecodecamp/models/learn/challenge_model.dart'; 6 | import 'package:freecodecamp/service/audio/audio_service.dart'; 7 | import 'package:stacked/stacked.dart'; 8 | 9 | class AudioPlayerViewmodel extends BaseViewModel { 10 | final audioService = locator().audioHandler; 11 | 12 | StreamController position = StreamController.broadcast(); 13 | 14 | Duration? _totalDuration; 15 | Duration? get totalDuration => _totalDuration; 16 | 17 | Duration searchTimeStamp( 18 | bool forwards, 19 | int currentPosition, 20 | EnglishAudio audio, 21 | ) { 22 | if (forwards) { 23 | return Duration( 24 | seconds: currentPosition + 2, 25 | ); 26 | } else { 27 | return Duration( 28 | milliseconds: currentPosition - 2, 29 | ); 30 | } 31 | } 32 | 33 | void initPositionListener() { 34 | AudioService.position.listen((event) { 35 | if (position.isClosed) { 36 | return; 37 | } 38 | 39 | position.add(event); 40 | }); 41 | } 42 | 43 | void onDispose() { 44 | position.close(); 45 | audioService.stop(); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /mobile-app/lib/ui/views/news/news-bookmark/news_bookmark_widget.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:freecodecamp/extensions/i18n_extension.dart'; 3 | import 'package:freecodecamp/ui/views/news/news-bookmark/news_bookmark_viewmodel.dart'; 4 | import 'package:freecodecamp/ui/views/news/news-tutorial/news_tutorial_view.dart'; 5 | import 'package:stacked/stacked.dart'; 6 | 7 | class NewsBookmarkViewWidget extends StatelessWidget { 8 | const NewsBookmarkViewWidget({ 9 | Key? key, 10 | required this.tutorial, 11 | }) : super(key: key); 12 | 13 | final dynamic tutorial; 14 | 15 | @override 16 | Widget build(BuildContext context) { 17 | return ViewModelBuilder.reactive( 18 | viewModelBuilder: () => NewsBookmarkViewModel(), 19 | onViewModelReady: (model) async { 20 | await model.initDB(); 21 | model.isTutorialBookmarked(tutorial); 22 | }, 23 | builder: (context, model, child) => BottomButton( 24 | key: const Key('bookmark_btn'), 25 | label: model.bookmarked 26 | ? context.t.tutorial_bookmarked 27 | : context.t.tutorial_bookmark, 28 | icon: model.bookmarked 29 | ? Icons.bookmark_added 30 | : Icons.bookmark_add_outlined, 31 | onPressed: () { 32 | model.bookmarkAndUnbookmark(tutorial); 33 | }, 34 | rightSided: false, 35 | ), 36 | ); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /mobile-app/android/app/google-services.json: -------------------------------------------------------------------------------- 1 | { 2 | "project_info": { 3 | "project_number": "363152234407", 4 | "project_id": "mobile-4ee8a", 5 | "storage_bucket": "mobile-4ee8a.appspot.com" 6 | }, 7 | "client": [ 8 | { 9 | "client_info": { 10 | "mobilesdk_app_id": "1:363152234407:android:6293f9873ae6df8a738882", 11 | "android_client_info": { 12 | "package_name": "org.freecodecamp" 13 | } 14 | }, 15 | "oauth_client": [ 16 | { 17 | "client_id": "363152234407-7si5dsj66c61lohgt73o1fh8kndqchpe.apps.googleusercontent.com", 18 | "client_type": 3 19 | } 20 | ], 21 | "api_key": [ 22 | { 23 | "current_key": "AIzaSyCP3yAWau0JJE8BPgsUrbT2K27EJX3z2KE" 24 | } 25 | ], 26 | "services": { 27 | "appinvite_service": { 28 | "other_platform_oauth_client": [ 29 | { 30 | "client_id": "363152234407-7si5dsj66c61lohgt73o1fh8kndqchpe.apps.googleusercontent.com", 31 | "client_type": 3 32 | }, 33 | { 34 | "client_id": "363152234407-qb25f7aak3egr8iod13bhsltjgf8viuq.apps.googleusercontent.com", 35 | "client_type": 2, 36 | "ios_info": { 37 | "bundle_id": "org.freecodecamp.ios" 38 | } 39 | } 40 | ] 41 | } 42 | } 43 | } 44 | ], 45 | "configuration_version": "1" 46 | } -------------------------------------------------------------------------------- /mobile-app/lib/ui/widgets/drawer_widget/drawer_tile.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class DrawerTile extends StatefulWidget { 4 | const DrawerTile({ 5 | Key? key, 6 | this.textColor = Colors.white, 7 | required this.component, 8 | required this.icon, 9 | required this.route, 10 | }) : super(key: key); 11 | 12 | final String component; 13 | final dynamic icon; 14 | final Function route; 15 | final Color? textColor; 16 | @override 17 | State createState() => _DrawerTileState(); 18 | } 19 | 20 | class _DrawerTileState extends State { 21 | @override 22 | Widget build(BuildContext context) { 23 | return Padding( 24 | padding: const EdgeInsets.all(10.0), 25 | child: ListTile( 26 | dense: true, 27 | leading: widget.icon != '' 28 | ? Icon( 29 | widget.icon, 30 | color: widget.textColor, 31 | ) 32 | : Image.asset( 33 | 'assets/images/logo.png', 34 | width: 30, 35 | height: 30, 36 | color: Colors.white, 37 | ), 38 | title: Text( 39 | widget.component, 40 | style: TextStyle( 41 | fontSize: 16, 42 | fontWeight: FontWeight.w400, 43 | color: widget.textColor, 44 | letterSpacing: 0.5), 45 | ), 46 | onTap: () { 47 | widget.route(); 48 | }, 49 | ), 50 | ); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /mobile-app/lib/ui/views/learn/challenge/templates/multiple_choice/multiple_choice_viewmodel.dart: -------------------------------------------------------------------------------- 1 | import 'package:freecodecamp/app/app.locator.dart'; 2 | import 'package:freecodecamp/models/learn/challenge_model.dart'; 3 | import 'package:freecodecamp/service/learn/learn_service.dart'; 4 | import 'package:stacked/stacked.dart'; 5 | 6 | class MultipleChoiceViewmodel extends BaseViewModel { 7 | int _currentChoice = -1; 8 | int get currentChoice => _currentChoice; 9 | 10 | bool? _choiceStatus; 11 | bool? get choiceStatus => _choiceStatus; 12 | 13 | String _errMessage = ''; 14 | String get errMessage => _errMessage; 15 | 16 | List _assignmentStatus = []; 17 | List get assignmentStatus => _assignmentStatus; 18 | 19 | final LearnService learnService = locator(); 20 | 21 | set setCurrentChoice(int choice) { 22 | _currentChoice = choice; 23 | notifyListeners(); 24 | } 25 | 26 | set setChoiceStatus(bool? status) { 27 | _choiceStatus = status; 28 | notifyListeners(); 29 | } 30 | 31 | set setErrMessage(String message) { 32 | _errMessage = message; 33 | notifyListeners(); 34 | } 35 | 36 | set setAssignmentStatus(List status) { 37 | _assignmentStatus = status; 38 | notifyListeners(); 39 | } 40 | 41 | void initChallenge(Challenge challenge) { 42 | setAssignmentStatus = 43 | List.filled(challenge.assignments?.length ?? 0, false); 44 | } 45 | 46 | void checkOption(Challenge challenge) async { 47 | bool isCorrect = challenge.question!.solution - 1 == currentChoice; 48 | setChoiceStatus = isCorrect; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /.github/workflows/crowdin-upload.yml: -------------------------------------------------------------------------------- 1 | name: i18n - Upload 2 | on: 3 | workflow_dispatch: 4 | # schedule: 5 | # # runs every weekday at 11:30 AM UTC 6 | # - cron: '30 11 * * 1-5' 7 | 8 | env: 9 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 10 | CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_CAMPERBOT_SERVICE_TOKEN }} 11 | CROWDIN_API_URL: "https://freecodecamp.crowdin.com/api/v2/" 12 | CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }} 13 | 14 | jobs: 15 | i18n-upload-files: 16 | name: Learn 17 | runs-on: ubuntu-22.04 18 | 19 | steps: 20 | - name: Checkout Source Files 21 | uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4 22 | 23 | - name: Generate Crowdin Config 24 | uses: freecodecamp/crowdin-action@main 25 | env: 26 | PLUGIN: "generate-config" 27 | PROJECT_NAME: "mobile" 28 | 29 | - name: Crowdin Upload 30 | uses: crowdin/github-action@master 31 | # options: https://github.com/crowdin/github-action/blob/master/action.yml 32 | with: 33 | # uploads 34 | upload_sources: true 35 | upload_translations: false 36 | auto_approve_imported: false 37 | import_eq_suggestions: false 38 | 39 | # downloads 40 | download_translations: false 41 | 42 | # pull-request 43 | create_pull_request: false 44 | 45 | # global options 46 | config: "./crowdin-config.yml" 47 | base_url: ${{ secrets.CROWDIN_BASE_URL_FCC }} 48 | 49 | # Uncomment below to debug 50 | # dryrun_action: true 51 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2024, freeCodeCamp.org 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /mobile-app/lib/models/code-radio/code_radio_model.dart: -------------------------------------------------------------------------------- 1 | class CodeRadio { 2 | const CodeRadio({ 3 | required this.id, 4 | required this.listenUrl, 5 | required this.totalListeners, 6 | required this.duration, 7 | required this.elapsed, 8 | required this.nowPlaying, 9 | required this.nextPlaying, 10 | }); 11 | 12 | final int id; 13 | final String listenUrl; 14 | 15 | final int totalListeners; 16 | 17 | final int duration; 18 | final int elapsed; 19 | 20 | final Song nowPlaying; 21 | final Song nextPlaying; 22 | 23 | factory CodeRadio.fromJson(Map data) { 24 | return CodeRadio( 25 | id: data['station']['id'], 26 | listenUrl: data['station']['listen_url'], 27 | totalListeners: data['listeners']['total'], 28 | elapsed: data['now_playing']['elapsed'], 29 | duration: data['now_playing']['duration'], 30 | nowPlaying: Song.fromJson(data['now_playing']['song']), 31 | nextPlaying: Song.fromJson(data['playing_next']['song'])); 32 | } 33 | } 34 | 35 | class Song { 36 | const Song( 37 | {required this.title, 38 | required this.artist, 39 | required this.album, 40 | required this.artUrl, 41 | required this.id}); 42 | 43 | final String title; 44 | final String artist; 45 | final String album; 46 | final String artUrl; 47 | final String id; 48 | 49 | factory Song.fromJson(Map data) { 50 | return Song( 51 | title: data['title'], 52 | artist: data['artist'], 53 | album: data['album'], 54 | artUrl: data['art'], 55 | id: data['id']); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /mobile-api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@freecodecamp/mobile-api", 3 | "version": "1.0.0", 4 | "author": "freeCodeCamp ", 5 | "license": "BSD-3-Clause", 6 | "private": true, 7 | "engines": { 8 | "node": ">=16", 9 | "npm": ">=8" 10 | }, 11 | "scripts": { 12 | "build": "tsc", 13 | "dev": "nodemon src/index.ts", 14 | "lint": "npm run lint:prettier && npm run lint:src", 15 | "lint:src": "eslint --max-warnings 0 .", 16 | "lint:prettier": "prettier --check .", 17 | "prettier:fix": "prettier --write .", 18 | "start": "NODE_ENV=production node prod/index.js", 19 | "test": "jest", 20 | "pre:build:schema": "cd ../mobile-app && dart run --enable-asserts lib/scripts/init_validation_schema.dart && mv ./lib/scripts/schema-keys.json ../mobile-api/src/schema-keys.json" 21 | }, 22 | "dependencies": { 23 | "node-fetch": "3.3.2", 24 | "bree": "9.2.3", 25 | "dotenv": "16.4.5", 26 | "express": "4.19.2", 27 | "mongoose": "8.4.1", 28 | "rss-parser": "3.13.0" 29 | }, 30 | "devDependencies": { 31 | "@babel/preset-typescript": "7.24.7", 32 | "@types/jest": "29.5.12", 33 | "@babel/preset-env": "7.24.7", 34 | "@babel/plugin-transform-typescript": "7.24.7", 35 | "@breejs/ts-worker": "2.0.0", 36 | "@types/express": "4.17.21", 37 | "@types/node": "20.14.2", 38 | "@typescript-eslint/eslint-plugin": "7.13.0", 39 | "@typescript-eslint/parser": "7.13.0", 40 | "eslint": "8.57.0", 41 | "eslint-config-prettier": "9.1.0", 42 | "nodemon": "3.1.3", 43 | "prettier": "3.3.2", 44 | "ts-jest": "29.1.4", 45 | "ts-node": "10.9.2", 46 | "typescript": "5.4.5" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /mobile-app/test/helpers/test_helpers.dart: -------------------------------------------------------------------------------- 1 | import 'package:freecodecamp/app/app.locator.dart'; 2 | import 'package:freecodecamp/service/news/bookmark_service.dart'; 3 | import 'package:mockito/annotations.dart'; 4 | import 'package:stacked_services/stacked_services.dart'; 5 | // @stacked-import 6 | 7 | import 'test_helpers.mocks.dart'; 8 | 9 | @GenerateMocks([], customMocks: [ 10 | MockSpec(onMissingStub: OnMissingStub.returnDefault), 11 | MockSpec(onMissingStub: OnMissingStub.returnDefault), 12 | // @stacked-mock-spec 13 | ]) 14 | void registerServices() { 15 | getAndRegisterNavigationService(); 16 | getAndRegisterDialogService(); 17 | getAndRegisterNewsBookmarkService(); 18 | // @stacked-mock-register 19 | } 20 | 21 | MockNavigationService getAndRegisterNavigationService() { 22 | _removeRegistrationIfExists(); 23 | final service = MockNavigationService(); 24 | locator.registerSingleton(service); 25 | return service; 26 | } 27 | 28 | MockDialogService getAndRegisterDialogService() { 29 | _removeRegistrationIfExists(); 30 | final service = MockDialogService(); 31 | locator.registerSingleton(service); 32 | return service; 33 | } 34 | 35 | BookmarksDatabaseService getAndRegisterNewsBookmarkService() { 36 | _removeRegistrationIfExists(); 37 | final service = BookmarksDatabaseService(); 38 | locator.registerSingleton(service); 39 | return service; 40 | } 41 | // @stacked-mock-create 42 | 43 | void _removeRegistrationIfExists() { 44 | if (locator.isRegistered()) { 45 | locator.unregister(); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /mobile-app/lib/ui/views/learn/widgets/progressbar_widget.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:freecodecamp/models/learn/curriculum_model.dart'; 3 | import 'package:freecodecamp/ui/views/learn/block/block_viewmodel.dart'; 4 | 5 | class ChallengeProgressBar extends StatelessWidget { 6 | const ChallengeProgressBar({ 7 | Key? key, 8 | required this.block, 9 | required this.model, 10 | }) : super(key: key); 11 | 12 | final Block block; 13 | final BlockViewModel model; 14 | 15 | @override 16 | Widget build(BuildContext context) { 17 | return Container( 18 | color: const Color(0xFF0a0a23), 19 | child: Container( 20 | margin: const EdgeInsets.only(bottom: 1), 21 | padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), 22 | child: Row( 23 | children: [ 24 | Expanded( 25 | child: LinearProgressIndicator( 26 | color: const Color.fromRGBO(0x19, 0x8e, 0xee, 1), 27 | backgroundColor: const Color.fromRGBO(0x2A, 0x2A, 0x40, 1), 28 | minHeight: 10, 29 | value: model.challengesCompleted / block.challenges.length, 30 | ), 31 | ), 32 | Padding( 33 | padding: const EdgeInsets.all(8.0), 34 | child: Text( 35 | '${(model.challengesCompleted / block.challenges.length * 100).round().toString()}%', 36 | style: const TextStyle( 37 | fontSize: 18, 38 | fontWeight: FontWeight.bold, 39 | ), 40 | ), 41 | ) 42 | ], 43 | ), 44 | ), 45 | ); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /mobile-app/ios/Runner/Base.lproj/Main.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /mobile-app/ios/Podfile: -------------------------------------------------------------------------------- 1 | # Uncomment this line to define a global platform for your project 2 | platform :ios, '13.0' 3 | 4 | # CocoaPods analytics sends network stats synchronously affecting flutter build latency. 5 | ENV['COCOAPODS_DISABLE_STATS'] = 'true' 6 | 7 | project 'Runner', { 8 | 'Debug' => :debug, 9 | 'Profile' => :release, 10 | 'Release' => :release, 11 | } 12 | 13 | def flutter_root 14 | generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) 15 | unless File.exist?(generated_xcode_build_settings_path) 16 | raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" 17 | end 18 | 19 | File.foreach(generated_xcode_build_settings_path) do |line| 20 | matches = line.match(/FLUTTER_ROOT\=(.*)/) 21 | return matches[1].strip if matches 22 | end 23 | raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" 24 | end 25 | 26 | require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) 27 | 28 | flutter_ios_podfile_setup 29 | 30 | target 'Runner' do 31 | use_frameworks! 32 | use_modular_headers! 33 | 34 | flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) 35 | end 36 | 37 | post_install do |installer| 38 | installer.pods_project.targets.each do |target| 39 | flutter_additional_ios_build_settings(target) 40 | 41 | target.build_configurations.each do |config| 42 | config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] ||= [ 43 | '$(inherited)', 44 | 'AUDIO_SESSION_MICROPHONE=0' 45 | ] 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /mobile-app/lib/service/locale_service.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:flutter/widgets.dart'; 4 | import 'package:shared_preferences/shared_preferences.dart'; 5 | 6 | class LocaleService { 7 | Locale locale = const Locale('en'); 8 | 9 | // supported locales 10 | List locales = [ 11 | // Enlish 12 | const Locale('en'), 13 | // Spanish 14 | const Locale('es'), 15 | // Portuguese 16 | const Locale('pt'), 17 | ]; 18 | 19 | List localeNames = [ 20 | 'English', 21 | 'Spanish', 22 | 'Portuguese', 23 | ]; 24 | 25 | String currentLocaleName = 'English'; 26 | 27 | Stream get localeStream => _localeController.stream; 28 | final _localeController = StreamController.broadcast(); 29 | 30 | void changeLocale(String locale, {isLocaleCode = false}) { 31 | int localeIndex = 0; 32 | 33 | if (!isLocaleCode) { 34 | localeIndex = localeNames.indexWhere((element) { 35 | return locale == element; 36 | }); 37 | 38 | currentLocaleName = localeNames[localeIndex]; 39 | } else { 40 | int index = locales.indexWhere((element) { 41 | return locale == element.languageCode; 42 | }); 43 | 44 | currentLocaleName = localeNames[index]; 45 | } 46 | 47 | this.locale = Locale.fromSubtags( 48 | languageCode: isLocaleCode ? locale : locales[localeIndex].languageCode, 49 | ); 50 | 51 | SharedPreferences.getInstance().then((prefs) { 52 | prefs.setString( 53 | 'locale', 54 | isLocaleCode ? locale : locales[localeIndex].languageCode, 55 | ); 56 | }); 57 | 58 | _localeController.sink.add(this.locale); 59 | } 60 | 61 | void init() async { 62 | SharedPreferences prefs = await SharedPreferences.getInstance(); 63 | String? locale = prefs.getString('locale'); 64 | 65 | if (locale != null) { 66 | changeLocale(locale, isLocaleCode: true); 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /mobile-app/lib/ui/theme/fcc_theme.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | 3 | class FccTheme { 4 | static ThemeData themeDark = ThemeData( 5 | brightness: Brightness.dark, 6 | fontFamily: 'RobotoMono', 7 | scaffoldBackgroundColor: const Color.fromRGBO(0x2A, 0x2A, 0x40, 1), 8 | appBarTheme: const AppBarTheme( 9 | centerTitle: true, 10 | color: Color(0xFF0a0a23), 11 | scrolledUnderElevation: 0, 12 | ), 13 | bottomNavigationBarTheme: const BottomNavigationBarThemeData( 14 | backgroundColor: Color(0xFF0a0a23), 15 | unselectedItemColor: Color(0x99FFFFFF), 16 | selectedItemColor: Colors.white, 17 | ), 18 | elevatedButtonTheme: ElevatedButtonThemeData( 19 | style: ElevatedButton.styleFrom( 20 | backgroundColor: const Color(0xFF0a0a23), 21 | foregroundColor: Colors.white, 22 | ), 23 | ), 24 | textButtonTheme: TextButtonThemeData( 25 | style: ButtonStyle( 26 | backgroundColor: WidgetStateProperty.resolveWith( 27 | (states) => const Color.fromRGBO(0x3b, 0x3b, 0x4f, 1)), 28 | foregroundColor: 29 | WidgetStateProperty.resolveWith((states) => Colors.white), 30 | overlayColor: WidgetStateProperty.resolveWith( 31 | (states) => const Color(0x4DFFFFFF), 32 | ), 33 | ), 34 | ), 35 | canvasColor: Colors.white, 36 | colorScheme: const ColorScheme.dark( 37 | primary: Colors.white, 38 | secondary: Color.fromRGBO(0xa9, 0xaa, 0xb2, 1), 39 | surface: Color(0xFF0a0a23), 40 | error: Colors.red, 41 | ), 42 | primaryColorDark: const Color(0xFF0a0a23), 43 | primaryIconTheme: ThemeData.dark().primaryIconTheme.copyWith( 44 | color: Colors.orange, 45 | ), 46 | textSelectionTheme: const TextSelectionThemeData( 47 | cursorColor: Color.fromRGBO(66, 133, 244, 1.0), 48 | selectionHandleColor: Color.fromARGB(255, 255, 255, 255), 49 | ), 50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /mobile-app/lib/service/navigation/quick_actions_service.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:freecodecamp/app/app.locator.dart'; 4 | import 'package:freecodecamp/app/app.router.dart'; 5 | import 'package:quick_actions/quick_actions.dart'; 6 | import 'package:stacked_services/stacked_services.dart'; 7 | 8 | class QuickActionsService { 9 | static final QuickActionsService _quickActionsService = 10 | QuickActionsService._internal(); 11 | 12 | QuickActions quickActions = const QuickActions(); 13 | 14 | final NavigationService _navigationService = locator(); 15 | 16 | factory QuickActionsService() { 17 | return _quickActionsService; 18 | } 19 | 20 | Future init() async { 21 | await quickActions.setShortcutItems([ 22 | const ShortcutItem( 23 | type: 'action_learn', 24 | localizedTitle: 'Learn', 25 | ), 26 | const ShortcutItem( 27 | type: 'action_tutorials', 28 | localizedTitle: 'Tutorials', 29 | ), 30 | if (!Platform.isIOS) 31 | const ShortcutItem( 32 | type: 'action_code_radio', 33 | localizedTitle: 'Code Radio', 34 | ), 35 | const ShortcutItem( 36 | type: 'action_podcasts', 37 | localizedTitle: 'Podcasts', 38 | ), 39 | ]); 40 | 41 | await quickActions.initialize((shortcutType) { 42 | switch (shortcutType) { 43 | case 'action_tutorials': 44 | _navigationService.replaceWith(Routes.newsViewHandlerView); 45 | break; 46 | case 'action_learn': 47 | _navigationService.replaceWith(Routes.learnLandingView); 48 | break; 49 | case 'action_code_radio': 50 | _navigationService.replaceWith(Routes.codeRadioView); 51 | break; 52 | case 'action_podcasts': 53 | _navigationService.replaceWith(Routes.podcastListView); 54 | break; 55 | default: 56 | } 57 | }); 58 | } 59 | 60 | QuickActionsService._internal(); 61 | } 62 | -------------------------------------------------------------------------------- /mobile-app/lib/models/podcasts/podcasts_model.dart: -------------------------------------------------------------------------------- 1 | class Podcasts { 2 | final String id; 3 | final String url; 4 | final String? link; 5 | final String? title; 6 | final String? description; 7 | final String? image; 8 | final String? copyright; 9 | final int? numEps; 10 | 11 | Podcasts({ 12 | required this.id, 13 | required this.url, 14 | this.link, 15 | this.title, 16 | this.description, 17 | this.image, 18 | this.copyright, 19 | this.numEps, 20 | }); 21 | 22 | factory Podcasts.fromAPIJson(Map json) => Podcasts( 23 | id: json['_id'] as String, 24 | url: json['feedUrl'] as String, 25 | link: json['podcastLink'] as String?, 26 | title: json['title'] as String?, 27 | description: json['description'] as String?, 28 | image: json['imageLink'] as String?, 29 | copyright: json['copyright'] as String?, 30 | numEps: json['numOfEps'] as int? 31 | ); 32 | 33 | factory Podcasts.fromDBJson(Map json) => Podcasts( 34 | id: json['id'] as String, 35 | url: json['url'] as String, 36 | link: json['link'] as String?, 37 | title: json['title'] as String?, 38 | description: json['description'] as String?, 39 | image: json['image'] as String?, 40 | copyright: json['copyright'] as String?, 41 | numEps: json['numEps'] as int? 42 | ); 43 | 44 | Map toJson() => { 45 | 'id': id, 46 | 'url': url, 47 | 'link': link, 48 | 'title': title, 49 | 'description': description, 50 | 'image': image, 51 | 'copyright': copyright, 52 | 'numEps': numEps 53 | }; 54 | 55 | @override 56 | String toString() { 57 | return '''Podcasts { 58 | id: $id, 59 | url: $url, 60 | link: $link, 61 | title: $title, 62 | description: ${description!.substring(0, 100)}, 63 | image: $image, 64 | copyright: $copyright 65 | numEps: $numEps 66 | }'''; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /mobile-app/integration_test/learn/learn_landing.dart: -------------------------------------------------------------------------------- 1 | import 'package:dio/dio.dart'; 2 | import 'package:flutter_test/flutter_test.dart'; 3 | import 'package:freecodecamp/main.dart' as app; 4 | import 'package:freecodecamp/service/authentication/authentication_service.dart'; 5 | import 'package:freecodecamp/service/learn/learn_service.dart'; 6 | import 'package:freecodecamp/ui/views/learn/landing/landing_view.dart'; 7 | import 'package:integration_test/integration_test.dart'; 8 | 9 | final dio = Dio(); 10 | 11 | void main() { 12 | final binding = IntegrationTestWidgetsFlutterBinding(); 13 | IntegrationTestWidgetsFlutterBinding.ensureInitialized(); 14 | 15 | testWidgets('LEARN - Learn Landing view', (WidgetTester tester) async { 16 | // Start app 17 | tester.printToConsole('Test starting'); 18 | await app.main(testing: true); 19 | await binding.convertFlutterSurfaceToImage(); 20 | await tester.pumpAndSettle(); 21 | await binding.takeScreenshot('learn/learn-landing'); 22 | 23 | String baseUrl = LearnService.baseUrl; 24 | final Response res = await dio.get('$baseUrl/available-superblocks.json'); 25 | List superBlocks = res.data['superblocks']; 26 | int publicSuperBlocks = 0; 27 | 28 | for (int i = 0; i < superBlocks.length; i++) { 29 | if (superBlocks[i]['public']) { 30 | publicSuperBlocks++; 31 | } 32 | } 33 | await tester.pumpAndSettle(); 34 | 35 | // Check if all superblocks are displayed 36 | final superBlockButtons = find.byType(SuperBlockButton); 37 | final publicSuperBlockButtons = find.byWidgetPredicate( 38 | (widget) => widget is SuperBlockButton && widget.button.public == true, 39 | ); 40 | expect(superBlockButtons, findsNWidgets(superBlocks.length)); 41 | expect(publicSuperBlockButtons, findsNWidgets(publicSuperBlocks)); 42 | 43 | // Check for login button 44 | expect(AuthenticationService.staticIsloggedIn, false); 45 | 46 | final loginButton = find.text('Sign in to save your progress'); 47 | expect(loginButton, findsOneWidget); 48 | }); 49 | } 50 | -------------------------------------------------------------------------------- /mobile-app/lib/service/firebase/remote_config_service.dart: -------------------------------------------------------------------------------- 1 | import 'dart:developer'; 2 | 3 | import 'package:firebase_crashlytics/firebase_crashlytics.dart'; 4 | import 'package:firebase_remote_config/firebase_remote_config.dart'; 5 | import 'package:flutter/services.dart'; 6 | import 'package:freecodecamp/utils/upgrade_controller.dart'; 7 | import 'package:upgrader/upgrader.dart'; 8 | 9 | class RemoteConfigService { 10 | static final RemoteConfigService _instance = RemoteConfigService._internal(); 11 | 12 | static final remoteConfig = FirebaseRemoteConfig.instance; 13 | 14 | factory RemoteConfigService() { 15 | return _instance; 16 | } 17 | 18 | Future init() async { 19 | try { 20 | await remoteConfig.setConfigSettings( 21 | RemoteConfigSettings( 22 | fetchTimeout: const Duration(minutes: 1), 23 | minimumFetchInterval: const Duration(hours: 1), 24 | ), 25 | ); 26 | await remoteConfig.setDefaults({ 27 | 'min_app_version': '5.0.0', 28 | }); 29 | 30 | await remoteConfig.fetchAndActivate(); 31 | 32 | remoteConfig.onConfigUpdated.listen((event) async { 33 | await remoteConfig.activate(); 34 | 35 | // Update the min app version 36 | UpgraderState currUpgradeState = upgraderController.state; 37 | upgraderController.updateState(currUpgradeState.copyWith( 38 | minAppVersion: Upgrader.parseVersion( 39 | remoteConfig.getString('min_app_version'), 40 | 'minAppVersion', 41 | false, 42 | ), 43 | )); 44 | }); 45 | } on PlatformException catch (exception, stack) { 46 | log('Platform exception - $exception'); 47 | await FirebaseCrashlytics.instance.recordError( 48 | exception, 49 | stack, 50 | reason: 'Remote Config Platform Exception', 51 | ); 52 | } catch (exception) { 53 | log('Unable to fetch remote config. Cached or default values will be used $exception'); 54 | } 55 | } 56 | 57 | RemoteConfigService._internal(); 58 | } 59 | -------------------------------------------------------------------------------- /mobile-app/lib/ui/views/learn/challenge/templates/python-project/python_project_viewmodel.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:freecodecamp/app/app.locator.dart'; 3 | import 'package:freecodecamp/service/learn/learn_service.dart'; 4 | import 'package:stacked/stacked.dart'; 5 | 6 | class PythonProjectViewModel extends BaseViewModel { 7 | final LearnService learnService = locator(); 8 | 9 | TextEditingController linkController = TextEditingController(); 10 | 11 | final fCCRegex = RegExp( 12 | r'codepen\.io\/freecodecamp|freecodecamp\.rocks|github\.com\/freecodecamp|\.freecodecamp\.org', 13 | caseSensitive: false, 14 | ); 15 | final localhostRegex = RegExp(r'localhost:'); 16 | final httpRegex = RegExp(r'http(?!s|([^s]+?localhost))'); 17 | 18 | bool? _validLink; 19 | bool? get validLink => _validLink; 20 | 21 | String _linkErrMsg = ''; 22 | String get linkErrMsg => _linkErrMsg; 23 | 24 | set setValidLink(bool? status) { 25 | _validLink = status; 26 | notifyListeners(); 27 | } 28 | 29 | set setLinkErrMsg(String msg) { 30 | _linkErrMsg = msg; 31 | notifyListeners(); 32 | } 33 | 34 | bool isUrl(String url) { 35 | try { 36 | return Uri.parse(url).isAbsolute; 37 | } on FormatException { 38 | return false; 39 | } 40 | } 41 | 42 | void checkLink() { 43 | if (!isUrl(linkController.text)) { 44 | setValidLink = false; 45 | setLinkErrMsg = 'Please enter a valid link.'; 46 | return; 47 | } else if (fCCRegex.hasMatch(linkController.text)) { 48 | setValidLink = false; 49 | setLinkErrMsg = 'Remember to submit your own work.'; 50 | return; 51 | } else if (httpRegex.hasMatch(linkController.text)) { 52 | setValidLink = false; 53 | setLinkErrMsg = 'An unsecure (http) URL cannot be used.'; 54 | return; 55 | } else if (localhostRegex.hasMatch(linkController.text)) { 56 | setValidLink = false; 57 | setLinkErrMsg = 'Remember to submit a publicly visible app URL.'; 58 | return; 59 | } else { 60 | setValidLink = true; 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /.github/workflows/deploy-api.yml: -------------------------------------------------------------------------------- 1 | name: Build and Deploy API 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | 7 | jobs: 8 | build: 9 | name: Build 10 | runs-on: ubuntu-22.04 11 | 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | node-version: [ 20.x ] 16 | site_tlds: [ dev, org ] 17 | 18 | steps: 19 | - name: Checkout source code 20 | uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4 21 | 22 | - name: Use Node.js ${{ matrix.node-version }} 23 | uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4 24 | with: 25 | node-version: ${{ matrix.node-version }} 26 | 27 | - name: Install dependencies 28 | run: | 29 | cd mobile-api 30 | npm ci --no-audit --no-progress --no-optional --no-shrinkwrap 31 | 32 | - name: Build site 33 | env: 34 | NODE_ENV: production 35 | run: npm run build:api 36 | 37 | # - name: Set up Docker Buildx 38 | # uses: docker/setup-buildx-action@v1 39 | 40 | - name: Create a tagname 41 | id: tagname 42 | run: | 43 | echo "tagname=$(git rev-parse --short HEAD)-$(date +%Y%m%d)-$(date +%H%M)" >> $GITHUB_ENV 44 | 45 | - name: Build & Tag Images 46 | run: | 47 | cd mobile-api 48 | docker build . \ 49 | --tag registry.digitalocean.com/${{ secrets.DOCR_NAME }}/${{ matrix.site_tlds }}/mobile-api:$tagname \ 50 | --tag registry.digitalocean.com/${{ secrets.DOCR_NAME }}/${{ matrix.site_tlds }}/mobile-api:latest \ 51 | --file Dockerfile 52 | 53 | - name: Install doctl 54 | uses: digitalocean/action-doctl@v2 55 | with: 56 | token: ${{ secrets.DIGITALOCEAN_ACCESS_TOKEN }} 57 | 58 | - name: Log in to DigitalOcean Container Registry with short-lived credentials 59 | run: doctl registry login --expiry-seconds 1200 60 | 61 | - name: Push image to DigitalOcean Container Registry 62 | run: | 63 | docker push registry.digitalocean.com/${{ secrets.DOCR_NAME }}/${{ matrix.site_tlds }}/mobile-api:$tagname 64 | docker push registry.digitalocean.com/${{ secrets.DOCR_NAME }}/${{ matrix.site_tlds }}/mobile-api:latest 65 | -------------------------------------------------------------------------------- /mobile-app/lib/ui/views/learn/widgets/dynamic_panel/panels/pass/pass_widget_model.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | import 'dart:math'; 3 | 4 | import 'package:flutter/services.dart'; 5 | import 'package:freecodecamp/app/app.locator.dart'; 6 | import 'package:freecodecamp/models/learn/challenge_model.dart'; 7 | import 'package:freecodecamp/models/learn/completed_challenge_model.dart'; 8 | import 'package:freecodecamp/models/learn/motivational_quote_model.dart'; 9 | import 'package:freecodecamp/models/main/user_model.dart'; 10 | import 'package:freecodecamp/service/authentication/authentication_service.dart'; 11 | import 'package:freecodecamp/ui/views/learn/challenge/challenge_viewmodel.dart'; 12 | import 'package:stacked/stacked.dart'; 13 | 14 | class PassWidgetModel extends BaseViewModel { 15 | final AuthenticationService auth = locator(); 16 | 17 | late FccUserModel? _user; 18 | 19 | void init() async { 20 | _user = await auth.userModel; 21 | notifyListeners(); 22 | } 23 | 24 | Future numCompletedChallenges( 25 | ChallengeViewModel challengeModel, 26 | int challengesCompleted, 27 | ) async { 28 | if (_user != null) { 29 | List? completedChallenges = 30 | _user?.completedChallenges; 31 | Challenge? currChallenge = await challengeModel.challenge; 32 | if (currChallenge != null && completedChallenges != null) { 33 | if (completedChallenges 34 | .indexWhere((element) => element.id == currChallenge.id) != 35 | -1) { 36 | return challengesCompleted; 37 | } else { 38 | return challengesCompleted + 1; 39 | } 40 | } 41 | } 42 | return 0; 43 | } 44 | 45 | Future retrieveNewQuote() async { 46 | String path = 'assets/learn/motivational-quotes.json'; 47 | String file = await rootBundle.loadString(path); 48 | 49 | int quoteLength = jsonDecode(file)['motivationalQuotes'].length; 50 | 51 | Random random = Random(); 52 | 53 | int randomValue = random.nextInt(quoteLength); 54 | 55 | dynamic json = jsonDecode(file)['motivationalQuotes'][randomValue]; 56 | 57 | MotivationalQuote quote = MotivationalQuote.fromJson(json); 58 | 59 | return quote; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /mobile-api/src/jobs/update-podcasts.ts: -------------------------------------------------------------------------------- 1 | import Parser from 'rss-parser'; 2 | import { parentPort } from 'worker_threads'; 3 | import dbConnect from '../db-connect'; 4 | import Episode from '../models/Episode'; 5 | import PodcastModel, { Podcast } from '../models/Podcast'; 6 | import { feedUrls } from '../podcast-feed-urls.json'; 7 | 8 | console.log('Job running at', new Date().toISOString()); 9 | const parser = new Parser< 10 | Record, 11 | { itunes?: { duration: string } } 12 | >({ 13 | headers: { 14 | Accept: 'application/rss+xml, text/xml; q=0.1', 15 | }, 16 | }); 17 | 18 | void (async function () { 19 | await dbConnect(); 20 | for (const feedUrl of feedUrls) { 21 | const feed = await parser.parseURL(feedUrl); 22 | console.log('UPDATING PODCAST', feed.title); 23 | const podcast = await PodcastModel.findOneAndUpdate( 24 | { feedUrl: feedUrl }, 25 | { 26 | title: feed.title, 27 | description: feed.description, 28 | feedUrl: feedUrl, 29 | podcastLink: feed.link, 30 | imageLink: feed.image?.url || feed.itunes?.image, 31 | copyright: feed.copyright as string, 32 | numOfEps: feed.items.length, 33 | }, 34 | { 35 | new: true, 36 | upsert: true, 37 | }, 38 | ); 39 | for (const episode of feed.items) { 40 | const dateRaw = episode.isoDate ?? episode.pubDate ?? null; 41 | const episodeDate = dateRaw ? Date.parse(dateRaw) : null; 42 | await Episode.findOneAndUpdate( 43 | { 44 | podcastId: podcast._id, 45 | guid: episode.guid, 46 | }, 47 | { 48 | guid: episode.guid, 49 | podcastId: podcast._id, 50 | title: episode.title, 51 | description: episode.content, 52 | publicationDate: episodeDate, 53 | audioUrl: episode.enclosure?.url, 54 | duration: episode.itunes?.duration, 55 | }, 56 | { 57 | new: true, 58 | upsert: true, 59 | }, 60 | ); 61 | } 62 | } 63 | if (parentPort) { 64 | console.log('Job finished at', new Date().toISOString()); 65 | parentPort.postMessage('done'); 66 | } else { 67 | console.log('Job finished at', new Date().toISOString()); 68 | process.exit(0); 69 | } 70 | })(); 71 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![freeCodeCamp.org Social Banner](https://s3.amazonaws.com/freecodecamp/wide-social-banner.png) 2 | 3 | [![Pull Requests Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat)](http://makeapullrequest.com) 4 | [![first-timers-only Friendly](https://img.shields.io/badge/first--timers--only-friendly-blue.svg)](http://www.firsttimersonly.com/) 5 | 6 | ## freeCodeCamp.org's open-source mobile app 7 | 8 | [freeCodeCamp.org](https://www.freecodecamp.org) is an online learning platform that offers a comprehensive curriculum in web development and machine learning. The curriculum is self-paced and available free of charge. Our platform offers interactive coding challenges to help learners expand their skills. 9 | 10 | To provide more flexibility, we have adapted the curriculum to a flutter based mobile application. The mobile app aims to provide an alternative option for users who are not able to access a computer or high-speed internet connection. This app may be useful for those who prefer to learn on-the-go or need offline access to the curriculum. 11 | 12 | ### Roadmap 13 | At freeCodeCamp, we're always working to improve our mobile app for our community of learners. Here's a glimpse of what's to come in the future: 14 | 15 | - Our developers Nirajn2311 and Sembauke have been hard at work to make sure all of our tutorials, podcasts, Code Radio, and 'Learn' feature are available on the app. 16 | - We are now open to hear your feedback, opinion and great ideas to make the app even more user friendly and tailored to your need. 17 | - In the upcoming updates, we will be focusing on incorporating more interactive features and improving the overall user experience. 18 | 19 | We're always looking to make your learning experience even better, and we're excited to hear your thoughts on how we can make the app more valuable to you. 20 | 21 | ### Contributing 22 | The freeCodeCamp.org community is possible thanks to thousands of kind volunteers like you. We welcome all contributions to the community and are excited to welcome you aboard. 23 | 24 | > #### [Please follow these steps to contribute](https://contribute.freecodecamp.org/#/how-to-setup-freecodecamp-mobile-app-locally). 25 | 26 | #### If you have any other issues getting started please contact us on the [freeCodeCamp Discord Server](https://discord.gg/Z7Fm39aNtZ). 27 | -------------------------------------------------------------------------------- /mobile-app/lib/ui/views/settings/settings_viewmodel.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_custom_tabs/flutter_custom_tabs.dart'; 3 | import 'package:freecodecamp/app/app.locator.dart'; 4 | import 'package:freecodecamp/enums/dialog_type.dart'; 5 | import 'package:freecodecamp/extensions/i18n_extension.dart'; 6 | import 'package:freecodecamp/service/developer_service.dart'; 7 | import 'package:freecodecamp/service/locale_service.dart'; 8 | import 'package:freecodecamp/ui/widgets/setup_dialog_ui.dart'; 9 | import 'package:shared_preferences/shared_preferences.dart'; 10 | import 'package:stacked/stacked.dart'; 11 | import 'package:stacked_services/stacked_services.dart'; 12 | 13 | class SettingsViewModel extends BaseViewModel { 14 | final DialogService _dialogService = locator(); 15 | final LocaleService localeService = locator(); 16 | final developerService = locator(); 17 | 18 | bool isDev = false; 19 | 20 | void init() async { 21 | setupDialogUi(); 22 | isDev = await developerService.developmentMode(); 23 | notifyListeners(); 24 | } 25 | 26 | void resetCache(BuildContext context) async { 27 | DialogResponse? res = await _dialogService.showCustomDialog( 28 | barrierDismissible: true, 29 | variant: DialogType.buttonForm, 30 | title: context.t.settings_reset_cache, 31 | description: context.t.settings_reset_cache_description, 32 | mainButtonTitle: context.t.settings_reset_cache_confirm, 33 | ); 34 | 35 | if (res?.confirmed == true) { 36 | SharedPreferences prefs = await SharedPreferences.getInstance(); 37 | prefs.clear(); 38 | } 39 | } 40 | 41 | void openPrivacyPolicy() { 42 | launchUrl( 43 | Uri.parse('https://www.freecodecamp.org/news/privacy-policy/'), 44 | customTabsOptions: const CustomTabsOptions( 45 | shareState: CustomTabsShareState.on, 46 | urlBarHidingEnabled: true, 47 | showTitle: true, 48 | browser: CustomTabsBrowserConfiguration( 49 | fallbackCustomTabs: [ 50 | 'org.mozilla.firefox', 51 | 'com.microsoft.emmx', 52 | ], 53 | ), 54 | ), 55 | safariVCOptions: const SafariViewControllerOptions( 56 | barCollapsingEnabled: true, 57 | ), 58 | ); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /mobile-app/lib/ui/views/podcast/podcast-list/podcast_list_viewmodel.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:freecodecamp/app/app.locator.dart'; 4 | import 'package:freecodecamp/models/podcasts/podcasts_model.dart'; 5 | import 'package:freecodecamp/service/developer_service.dart'; 6 | import 'package:freecodecamp/service/dio_service.dart'; 7 | import 'package:freecodecamp/service/podcast/podcasts_service.dart'; 8 | import 'package:path_provider/path_provider.dart'; 9 | import 'package:stacked/stacked.dart'; 10 | 11 | const fccPodcastUrls = [ 12 | // English 13 | 'https://freecodecamp.libsyn.com/rss', 14 | // Spanish 15 | 'https://anchor.fm/s/ff0092f4/podcast/rss', 16 | // Chinese 17 | 'https://anchor.fm/s/ff054de4/podcast/rss', 18 | // Portuguese 19 | 'https://anchor.fm/s/ff026c00/podcast/rss', 20 | ]; 21 | 22 | class PodcastListViewModel extends BaseViewModel { 23 | final _databaseService = locator(); 24 | final _developerService = locator(); 25 | static late Directory appDir; 26 | int _index = 0; 27 | final _dio = DioService.dio; 28 | 29 | int get index => _index; 30 | 31 | void setIndex(i) { 32 | _index = i; 33 | notifyListeners(); 34 | } 35 | 36 | Future init() async { 37 | appDir = await getApplicationDocumentsDirectory(); 38 | } 39 | 40 | void refresh() { 41 | notifyListeners(); 42 | } 43 | 44 | Future> fetchPodcasts(bool isDownloadView) async { 45 | String baseUrl = (await _developerService.developmentMode()) 46 | ? 'https://api.mobile.freecodecamp.dev' 47 | : 'https://api.mobile.freecodecamp.org'; 48 | await _databaseService.initialise(); 49 | if (isDownloadView) { 50 | return await _databaseService.getPodcasts(); 51 | } else { 52 | final res = await _dio.get('$baseUrl/podcasts'); 53 | final List podcasts = res.data; 54 | final podcastList = 55 | podcasts.map((podcast) => Podcasts.fromAPIJson(podcast)).toList(); 56 | final fccPodcasts = podcastList 57 | .where((podcast) => fccPodcastUrls.contains(podcast.url)) 58 | .toList(); 59 | final otherPodcasts = podcastList 60 | .where((podcast) => !fccPodcastUrls.contains(podcast.url)) 61 | .toList(); 62 | return [...fccPodcasts, ...otherPodcasts]; 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /mobile-app/ios/Runner/Info-Release.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CADisableMinimumFrameDurationOnPhone 6 | 7 | CFBundleDevelopmentRegion 8 | $(DEVELOPMENT_LANGUAGE) 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | freeCodeCamp 17 | CFBundlePackageType 18 | APPL 19 | CFBundleShortVersionString 20 | $(FLUTTER_BUILD_NAME) 21 | CFBundleSignature 22 | ???? 23 | CFBundleURLTypes 24 | 25 | 26 | CFBundleTypeRole 27 | None 28 | CFBundleURLName 29 | auth0 30 | CFBundleURLSchemes 31 | 32 | $(PRODUCT_BUNDLE_IDENTIFIER) 33 | 34 | 35 | 36 | CFBundleVersion 37 | $(FLUTTER_BUILD_NUMBER) 38 | ITSAppUsesNonExemptEncryption 39 | 40 | LSRequiresIPhoneOS 41 | 42 | NSAppTransportSecurity 43 | 44 | NSAllowsArbitraryLoads 45 | 46 | 47 | UIBackgroundModes 48 | 49 | audio 50 | 51 | UILaunchStoryboardName 52 | LaunchScreen 53 | UIMainStoryboardFile 54 | Main 55 | UISupportedInterfaceOrientations 56 | 57 | UIInterfaceOrientationPortrait 58 | UIInterfaceOrientationLandscapeLeft 59 | UIInterfaceOrientationLandscapeRight 60 | 61 | UISupportedInterfaceOrientations~ipad 62 | 63 | UIInterfaceOrientationPortrait 64 | UIInterfaceOrientationPortraitUpsideDown 65 | UIInterfaceOrientationLandscapeLeft 66 | UIInterfaceOrientationLandscapeRight 67 | 68 | UIViewControllerBasedStatusBarAppearance 69 | 70 | 71 | 72 | -------------------------------------------------------------------------------- /mobile-app/lib/ui/views/learn/widgets/dynamic_panel/panels/dynamic_panel.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:freecodecamp/enums/panel_type.dart'; 3 | import 'package:freecodecamp/models/learn/challenge_model.dart'; 4 | import 'package:freecodecamp/ui/views/learn/challenge/challenge_viewmodel.dart'; 5 | import 'package:freecodecamp/ui/views/learn/widgets/dynamic_panel/panels/description/description_widget_view.dart'; 6 | import 'package:freecodecamp/ui/views/learn/widgets/dynamic_panel/panels/hint/hint_widget_view.dart'; 7 | import 'package:freecodecamp/ui/views/learn/widgets/dynamic_panel/panels/pass/pass_widget_view.dart'; 8 | import 'package:phone_ide/phone_ide.dart'; 9 | 10 | class DynamicPanel extends StatelessWidget { 11 | const DynamicPanel({ 12 | Key? key, 13 | required this.challenge, 14 | required this.model, 15 | required this.panel, 16 | required this.maxChallenges, 17 | required this.challengesCompleted, 18 | required this.editor, 19 | }) : super(key: key); 20 | 21 | final Challenge challenge; 22 | final ChallengeViewModel model; 23 | final PanelType panel; 24 | final int maxChallenges; 25 | final int challengesCompleted; 26 | 27 | final Editor editor; 28 | 29 | Widget panelHandler(PanelType panel) { 30 | switch (panel) { 31 | case PanelType.instruction: 32 | return DescriptionView( 33 | description: challenge.description, 34 | instructions: challenge.instructions, 35 | challengeModel: model, 36 | maxChallenges: maxChallenges, 37 | title: challenge.title, 38 | ); 39 | case PanelType.pass: 40 | return PassWidgetView( 41 | challengeModel: model, 42 | challengesCompleted: challengesCompleted, 43 | maxChallenges: maxChallenges, 44 | ); 45 | case PanelType.hint: 46 | return HintWidgetView( 47 | hint: model.hint, 48 | challengeModel: model, 49 | editor: editor, 50 | ); 51 | default: 52 | return Container(); 53 | } 54 | } 55 | 56 | @override 57 | Widget build(BuildContext context) { 58 | return Container( 59 | height: MediaQuery.of(context).size.height * 0.75, 60 | color: const Color(0xFF0a0a23), 61 | child: Padding( 62 | padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), 63 | child: panelHandler(panel), 64 | ), 65 | ); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /mobile-app/ios/Runner/Info-Debug.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | freeCodeCamp 15 | CFBundlePackageType 16 | APPL 17 | CFBundleShortVersionString 18 | $(FLUTTER_BUILD_NAME) 19 | CFBundleSignature 20 | ???? 21 | CFBundleURLTypes 22 | 23 | 24 | CFBundleTypeRole 25 | None 26 | CFBundleURLName 27 | auth0 28 | CFBundleURLSchemes 29 | 30 | $(PRODUCT_BUNDLE_IDENTIFIER) 31 | 32 | 33 | 34 | CFBundleVersion 35 | $(FLUTTER_BUILD_NUMBER) 36 | ITSAppUsesNonExemptEncryption 37 | 38 | LSRequiresIPhoneOS 39 | 40 | NSAppTransportSecurity 41 | 42 | NSAllowsArbitraryLoads 43 | 44 | 45 | NSBonjourServices 46 | 47 | _dartobservatory._tcp 48 | 49 | UIBackgroundModes 50 | 51 | audio 52 | 53 | UILaunchStoryboardName 54 | LaunchScreen 55 | UIMainStoryboardFile 56 | Main 57 | UISupportedInterfaceOrientations 58 | 59 | UIInterfaceOrientationPortrait 60 | UIInterfaceOrientationLandscapeLeft 61 | UIInterfaceOrientationLandscapeRight 62 | 63 | UISupportedInterfaceOrientations~ipad 64 | 65 | UIInterfaceOrientationPortrait 66 | UIInterfaceOrientationPortraitUpsideDown 67 | UIInterfaceOrientationLandscapeLeft 68 | UIInterfaceOrientationLandscapeRight 69 | 70 | UIViewControllerBasedStatusBarAppearance 71 | 72 | 73 | 74 | -------------------------------------------------------------------------------- /mobile-app/lib/ui/views/news/news-bookmark/news_bookmark_feed_view.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:freecodecamp/extensions/i18n_extension.dart'; 3 | import 'package:freecodecamp/ui/views/news/news-bookmark/news_bookmark_viewmodel.dart'; 4 | import 'package:stacked/stacked.dart'; 5 | 6 | class NewsBookmarkFeedView extends StatelessWidget { 7 | const NewsBookmarkFeedView({ 8 | Key? key, 9 | }) : super(key: key); 10 | 11 | @override 12 | Widget build(BuildContext context) { 13 | return ViewModelBuilder.reactive( 14 | viewModelBuilder: () => NewsBookmarkViewModel(), 15 | onViewModelReady: (model) async { 16 | await model.initDB(); 17 | model.hasBookmarkedTutorials(); 18 | model.updateListView(); 19 | }, 20 | builder: (context, model, child) => Scaffold( 21 | backgroundColor: const Color.fromRGBO(0x2A, 0x2A, 0x40, 1), 22 | body: RefreshIndicator( 23 | backgroundColor: const Color(0xFF0a0a23), 24 | color: Colors.white, 25 | onRefresh: () { 26 | return model.refresh(); 27 | }, 28 | child: model.userHasBookmarkedTutorials 29 | ? populateListViewModel(model) 30 | : Center( 31 | child: Text( 32 | context.t.tutorial_no_bookmarks, 33 | textAlign: TextAlign.center, 34 | ), 35 | ), 36 | ), 37 | ), 38 | ); 39 | } 40 | } 41 | 42 | ListView populateListViewModel(NewsBookmarkViewModel model) { 43 | return ListView.separated( 44 | itemCount: model.count, 45 | separatorBuilder: (BuildContext context, int i) => const Divider( 46 | color: Colors.white, 47 | ), 48 | itemBuilder: (context, index) { 49 | var tutorial = model.bookMarkedTutorials[index]; 50 | 51 | return ListTile( 52 | key: Key('bookmark_tutorial_$index'), 53 | title: Text(tutorial.tutorialTitle), 54 | trailing: const Icon(Icons.arrow_forward_ios_sharp), 55 | subtitle: Padding( 56 | padding: const EdgeInsets.only(top: 8.0), 57 | child: Text('Written by: ${tutorial.authorName}'), 58 | ), 59 | onTap: () { 60 | model.routeToBookmarkedTutorial(tutorial); 61 | }, 62 | contentPadding: const EdgeInsets.all(16), 63 | minVerticalPadding: 8, 64 | ); 65 | }, 66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /mobile-app/lib/service/firebase/analytics_observer.dart: -------------------------------------------------------------------------------- 1 | import 'dart:developer'; 2 | 3 | import 'package:firebase_analytics/firebase_analytics.dart'; 4 | import 'package:flutter/widgets.dart'; 5 | import 'package:freecodecamp/app/app.router.dart'; 6 | 7 | class AnalyticsObserver extends RouteObserver { 8 | AnalyticsObserver({required this.analytics}); 9 | 10 | final FirebaseAnalytics analytics; 11 | 12 | void _sendScreenView(Route route) async { 13 | String screenName = route.settings.name ?? 'could-not-find-view'; 14 | 15 | if (route.settings.arguments != null) { 16 | switch (route.settings.arguments.runtimeType) { 17 | case SuperBlockViewArguments: 18 | final routeArgs = route.settings.arguments as SuperBlockViewArguments; 19 | screenName += '/${routeArgs.superBlockDashedName}'; 20 | break; 21 | case NewsTutorialViewArguments: 22 | final routeArgs = 23 | route.settings.arguments as NewsTutorialViewArguments; 24 | screenName += '/${routeArgs.title}'; 25 | break; 26 | case NewsBookmarkTutorialViewArguments: 27 | final routeArgs = 28 | route.settings.arguments as NewsBookmarkTutorialViewArguments; 29 | screenName += '/${routeArgs.tutorial.tutorialTitle}'; 30 | break; 31 | case ChallengeViewArguments: 32 | final routeArgs = route.settings.arguments as ChallengeViewArguments; 33 | screenName += '/${routeArgs.challengeId}'; 34 | break; 35 | default: 36 | screenName += '/${route.settings.arguments}'; 37 | } 38 | } 39 | log('Setting screen to $screenName'); 40 | await analytics.logScreenView( 41 | screenName: screenName, 42 | screenClass: screenName, 43 | ); 44 | } 45 | 46 | @override 47 | void didPush(Route route, Route? previousRoute) { 48 | super.didPush(route, previousRoute); 49 | _sendScreenView(route); 50 | } 51 | 52 | @override 53 | void didReplace({Route? newRoute, Route? oldRoute}) { 54 | super.didReplace(newRoute: newRoute, oldRoute: oldRoute); 55 | if (newRoute != null) { 56 | _sendScreenView(newRoute); 57 | } 58 | } 59 | 60 | @override 61 | void didPop(Route route, Route? previousRoute) { 62 | super.didPop(route, previousRoute); 63 | if (previousRoute != null) { 64 | _sendScreenView(previousRoute); 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /mobile-app/android/app/build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id "com.android.application" 3 | // START: FlutterFire Configuration 4 | id 'com.google.gms.google-services' 5 | id 'com.google.firebase.crashlytics' 6 | // END: FlutterFire Configuration 7 | id "kotlin-android" 8 | id "dev.flutter.flutter-gradle-plugin" 9 | } 10 | 11 | def localProperties = new Properties() 12 | def localPropertiesFile = rootProject.file('local.properties') 13 | if (localPropertiesFile.exists()) { 14 | localPropertiesFile.withReader('UTF-8') { reader -> 15 | localProperties.load(reader) 16 | } 17 | } 18 | 19 | def flutterVersionCode = localProperties.getProperty('flutter.versionCode') 20 | if (flutterVersionCode == null) { 21 | flutterVersionCode = '1' 22 | } 23 | 24 | def flutterVersionName = localProperties.getProperty('flutter.versionName') 25 | if (flutterVersionName == null) { 26 | flutterVersionName = '1.0' 27 | } 28 | 29 | def keystoreProperties = new Properties() 30 | def keystorePropertiesFile = rootProject.file('key.properties') 31 | if (keystorePropertiesFile.exists()) { 32 | keystoreProperties.load(new FileInputStream(keystorePropertiesFile)) 33 | } 34 | 35 | android { 36 | compileSdkVersion flutter.compileSdkVersion 37 | ndkVersion flutter.ndkVersion 38 | 39 | sourceSets { 40 | main.java.srcDirs += 'src/main/kotlin' 41 | } 42 | 43 | defaultConfig { 44 | applicationId "org.freecodecamp" 45 | minSdkVersion 22 46 | targetSdkVersion flutter.targetSdkVersion 47 | versionCode flutterVersionCode.toInteger() 48 | versionName flutterVersionName 49 | manifestPlaceholders += [ 50 | auth0Domain: "freecodecamp-dev.auth0.com", 51 | auth0Scheme: "fccapp" 52 | ] 53 | } 54 | 55 | signingConfigs { 56 | release { 57 | keyAlias keystoreProperties['keyAlias'] 58 | keyPassword keystoreProperties['keyPassword'] 59 | storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null 60 | storePassword keystoreProperties['storePassword'] 61 | } 62 | } 63 | 64 | buildTypes { 65 | release { 66 | signingConfig signingConfigs.release 67 | manifestPlaceholders['auth0Domain'] = "auth.freecodecamp.org" 68 | } 69 | } 70 | } 71 | 72 | flutter { 73 | source '../..' 74 | } 75 | 76 | dependencies { 77 | implementation 'com.google.android.material:material:1.11.0' 78 | } 79 | -------------------------------------------------------------------------------- /mobile-app/lib/ui/widgets/drawer_widget/drawer_web_buttton.dart: -------------------------------------------------------------------------------- 1 | import 'package:firebase_analytics/firebase_analytics.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:flutter_custom_tabs/flutter_custom_tabs.dart'; 4 | 5 | class CustomTabButton extends StatefulWidget { 6 | const CustomTabButton({ 7 | Key? key, 8 | required this.url, 9 | required this.icon, 10 | required this.component, 11 | }) : super(key: key); 12 | 13 | final String url; 14 | final String component; 15 | final IconData icon; 16 | 17 | void startCustomTabs(String url) async { 18 | String location; 19 | switch (url) { 20 | case 'https://www.freecodecamp.org/news/privacy-policy/': 21 | location = 'Privacy Policy'; 22 | break; 23 | case 'https://www.freecodecamp.org/donate/': 24 | location = 'Donation'; 25 | break; 26 | default: 27 | location = url; 28 | } 29 | await FirebaseAnalytics.instance.logScreenView( 30 | screenClass: 'Web View - $location', 31 | screenName: 'Web View - $location', 32 | ); 33 | launchUrl( 34 | Uri.parse(url), 35 | customTabsOptions: const CustomTabsOptions( 36 | shareState: CustomTabsShareState.on, 37 | urlBarHidingEnabled: true, 38 | showTitle: true, 39 | browser: CustomTabsBrowserConfiguration( 40 | fallbackCustomTabs: [ 41 | 'org.mozilla.firefox', 42 | 'com.microsoft.emmx', 43 | ], 44 | ), 45 | ), 46 | safariVCOptions: const SafariViewControllerOptions( 47 | barCollapsingEnabled: true, 48 | ), 49 | ); 50 | } 51 | 52 | @override 53 | State createState() => _CustomTabButtonState(); 54 | } 55 | 56 | class _CustomTabButtonState extends State { 57 | @override 58 | Widget build(BuildContext context) { 59 | return Padding( 60 | padding: const EdgeInsets.all(10.0), 61 | child: ListTile( 62 | dense: true, 63 | onTap: () { 64 | widget.startCustomTabs(widget.url); 65 | }, 66 | leading: Icon( 67 | widget.icon, 68 | color: Colors.white, 69 | ), 70 | title: Text( 71 | widget.component, 72 | style: const TextStyle( 73 | fontSize: 16, 74 | fontWeight: FontWeight.w400, 75 | color: Colors.white, 76 | letterSpacing: 0.5, 77 | ), 78 | ), 79 | ), 80 | ); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /mobile-app/ios/Runner/Info-Profile.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CADisableMinimumFrameDurationOnPhone 6 | 7 | CFBundleDevelopmentRegion 8 | $(DEVELOPMENT_LANGUAGE) 9 | CFBundleExecutable 10 | $(EXECUTABLE_NAME) 11 | CFBundleIdentifier 12 | $(PRODUCT_BUNDLE_IDENTIFIER) 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | freeCodeCamp 17 | CFBundlePackageType 18 | APPL 19 | CFBundleShortVersionString 20 | $(FLUTTER_BUILD_NAME) 21 | CFBundleSignature 22 | ???? 23 | CFBundleURLTypes 24 | 25 | 26 | CFBundleTypeRole 27 | None 28 | CFBundleURLName 29 | auth0 30 | CFBundleURLSchemes 31 | 32 | $(PRODUCT_BUNDLE_IDENTIFIER) 33 | 34 | 35 | 36 | CFBundleVersion 37 | $(FLUTTER_BUILD_NUMBER) 38 | ITSAppUsesNonExemptEncryption 39 | 40 | LSRequiresIPhoneOS 41 | 42 | NSAppTransportSecurity 43 | 44 | NSAllowsArbitraryLoads 45 | 46 | 47 | NSBonjourServices 48 | 49 | _dartobservatory._tcp 50 | 51 | UIBackgroundModes 52 | 53 | audio 54 | 55 | UILaunchStoryboardName 56 | LaunchScreen 57 | UIMainStoryboardFile 58 | Main 59 | UISupportedInterfaceOrientations 60 | 61 | UIInterfaceOrientationPortrait 62 | UIInterfaceOrientationLandscapeLeft 63 | UIInterfaceOrientationLandscapeRight 64 | 65 | UISupportedInterfaceOrientations~ipad 66 | 67 | UIInterfaceOrientationPortrait 68 | UIInterfaceOrientationPortraitUpsideDown 69 | UIInterfaceOrientationLandscapeLeft 70 | UIInterfaceOrientationLandscapeRight 71 | 72 | UIViewControllerBasedStatusBarAppearance 73 | 74 | 75 | 76 | -------------------------------------------------------------------------------- /mobile-app/ios/Runner/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /mobile-api/src/routes.ts: -------------------------------------------------------------------------------- 1 | import express, { Request, Response } from 'express'; 2 | import Parser from 'rss-parser'; 3 | import Episode from './models/Episode'; 4 | import Podcast from './models/Podcast'; 5 | import { feedUrls } from './podcast-feed-urls.json'; 6 | 7 | const router = express.Router(); 8 | const parser = new Parser(); 9 | 10 | router.get('/', (req, res, next) => { 11 | getPodcasts(req, res).catch(next); 12 | }); 13 | 14 | async function getPodcasts(req: Request, res: Response) { 15 | const podcasts = await Podcast.find({}); 16 | if (feedUrls.length !== podcasts.length) { 17 | console.log('Fetching missing podcasts'); 18 | for (const url of feedUrls) { 19 | const feed = await parser.parseURL(url); 20 | 21 | if (feed.title) { 22 | console.log(`${feed.title} ${feed.items.length}`); 23 | } else { 24 | console.error(`title of ${url} missing ${feed.items.length}`); 25 | } 26 | await Podcast.findOneAndUpdate( 27 | { feedUrl: url }, 28 | { 29 | title: feed.title, 30 | description: feed.description, 31 | feedUrl: url, 32 | podcastLink: feed.link, 33 | imageLink: feed.image?.url || feed.itunes?.image, 34 | copyright: feed.copyright as string, 35 | numOfEps: feed.items.length, 36 | }, 37 | { 38 | new: true, 39 | upsert: true, 40 | }, 41 | ); 42 | } 43 | res.json(await Podcast.find({})); 44 | } else { 45 | console.log('No missing podcasts'); 46 | res.json(podcasts); 47 | } 48 | } 49 | 50 | router.get('/:podcastId/episodes', (req, res, next) => { 51 | getEpisodes(req, res).catch(next); 52 | }); 53 | 54 | async function getEpisodes(req: Request, res: Response) { 55 | const podcast = await Podcast.findById(req.params.podcastId); 56 | if (!podcast) throw Error('Podcast not found'); 57 | const episodes = await Episode.find({ podcastId: podcast._id }) 58 | .sort({ publicationDate: -1 }) 59 | .skip(parseInt((req.query?.page as string) || '0') * 20) 60 | .limit(20); 61 | res.json({ podcast, episodes }); 62 | } 63 | 64 | router.get('/:podcastId/episodes/:episodeId', (req, res, next) => { 65 | getEpisode(req, res).catch(next); 66 | }); 67 | 68 | async function getEpisode(req: Request, res: Response) { 69 | const episode = await Episode.findOne({ 70 | podcastId: req.params.podcastId, 71 | _id: req.params.episodeId, 72 | }); 73 | if (!episode) throw Error('Episode not found'); 74 | res.json(episode); 75 | } 76 | 77 | export default router; 78 | -------------------------------------------------------------------------------- /mobile-app/android/app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 8 | 16 | 20 | 22 | 23 | 24 | 25 | 26 | 27 | 29 | 30 | 31 | 32 | 33 | 35 | 36 | 37 | 38 | 39 | 40 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /mobile-app/lib/ui/views/news/news-view-handler/news_view_handler_view.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:freecodecamp/extensions/i18n_extension.dart'; 3 | import 'package:freecodecamp/ui/views/news/news-bookmark/news_bookmark_feed_view.dart'; 4 | import 'package:freecodecamp/ui/views/news/news-feed/news_feed_view.dart'; 5 | import 'package:freecodecamp/ui/views/news/news-search/news_search_view.dart'; 6 | import 'package:freecodecamp/ui/views/news/news-view-handler/news_view_handler_viewmodel.dart'; 7 | import 'package:freecodecamp/ui/widgets/drawer_widget/drawer_widget_view.dart'; 8 | import 'package:stacked/stacked.dart'; 9 | 10 | class NewsViewHandlerView extends StatelessWidget { 11 | const NewsViewHandlerView({Key? key}) : super(key: key); 12 | 13 | @override 14 | Widget build(BuildContext context) { 15 | var titles = [ 16 | Text(context.t.tutorial_bookmarks_title), 17 | Text(context.t.tutorials), 18 | Text(context.t.tutorial_search_title) 19 | ]; 20 | 21 | const views = [ 22 | NewsBookmarkFeedView(), 23 | NewsFeedView(), 24 | NewsSearchView() 25 | ]; 26 | 27 | return ViewModelBuilder.reactive( 28 | builder: (context, model, child) => Scaffold( 29 | appBar: AppBar( 30 | title: titles.elementAt(model.index), 31 | ), 32 | drawer: const DrawerWidgetView(), 33 | body: views.elementAt(model.index), 34 | bottomNavigationBar: BottomNavigationBar( 35 | items: [ 36 | BottomNavigationBarItem( 37 | icon: const Icon( 38 | Icons.bookmark_outline_sharp, 39 | ), 40 | label: context.t.tutorial_nav_bookmarks, 41 | tooltip: context.t.tutorial_nav_bookmarks, 42 | ), 43 | BottomNavigationBarItem( 44 | icon: const Icon( 45 | Icons.article_sharp, 46 | ), 47 | label: context.t.tutorial_nav_tutorials, 48 | tooltip: context.t.tutorial_nav_tutorials, 49 | ), 50 | BottomNavigationBarItem( 51 | icon: const Icon( 52 | Icons.search_sharp, 53 | ), 54 | label: context.t.tutorial_nav_search, 55 | tooltip: context.t.tutorial_nav_search, 56 | ) 57 | ], 58 | currentIndex: model.index, 59 | onTap: model.onTapped, 60 | ), 61 | ), 62 | viewModelBuilder: () => NewsViewHandlerViewModel(), 63 | ); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /mobile-api/src/__tests__/podcast.test.ts: -------------------------------------------------------------------------------- 1 | import 'dotenv/config'; 2 | import fetch from 'node-fetch'; 3 | import { Podcast } from '../models/Podcast'; 4 | import { Episode } from '../models/Episode'; 5 | 6 | interface pingEndpoint { 7 | msg: string; 8 | } 9 | 10 | interface episodeEndpoint { 11 | podcast: Podcast; 12 | episodes: Array; 13 | } 14 | 15 | describe('podcast api', () => { 16 | const url = process.env.DEV_URL; 17 | 18 | let localPodcastId = ''; 19 | let localEpisodeId = ''; 20 | 21 | // test('the url should be available in the .env', () => { 22 | // expect(url.length).toBeGreaterThan(0); 23 | // }) 24 | 25 | test('it should be able to ping', async () => { 26 | const req = await fetch(`${url}/ping`); 27 | 28 | const res: pingEndpoint = (await req.json()) as pingEndpoint; 29 | 30 | expect(res.msg).toBe('pong'); 31 | }); 32 | 33 | test('it should have atleast 1 podcast available', async () => { 34 | const req = await fetch(`${url}/podcasts`); 35 | 36 | const res: Array = (await req.json()) as Array; 37 | 38 | expect(res.length).toBeGreaterThan(0); 39 | }); 40 | 41 | jest.setTimeout(30000); 42 | 43 | // this test is only viable when there are less than 10 podcast available as the time-out already is above normal 44 | 45 | test('all podcast should have episodes available', async () => { 46 | const req = await fetch(`${url}/podcasts`); 47 | 48 | const res: Array = (await req.json()) as Array; 49 | 50 | for (let i = 0; i < res.length; i++) { 51 | const podcastId = res[i]._id.toString(); 52 | 53 | const podcastRequest = await fetch( 54 | `${url}/podcasts/${podcastId}/episodes?page=0`, 55 | ); 56 | 57 | const podcastResult: episodeEndpoint = 58 | (await podcastRequest.json()) as episodeEndpoint; 59 | 60 | expect(podcastResult.episodes.length).toBeGreaterThan(0); 61 | expect(podcastResult.episodes.length).toBeLessThanOrEqual(20); 62 | 63 | localPodcastId = podcastResult.podcast._id.toString(); 64 | localEpisodeId = podcastResult.episodes[0]._id.toString(); 65 | } 66 | }); 67 | 68 | test(`a podcast should be available with the id: ${localPodcastId}`, async () => { 69 | expect(localPodcastId.length).toBeGreaterThan(0); 70 | expect(localEpisodeId.length).toBeGreaterThan(0); 71 | 72 | const req = await fetch( 73 | `${url}/podcasts/${localPodcastId}/episodes/${localEpisodeId}`, 74 | ); 75 | 76 | const res: Episode = (await req.json()) as Episode; 77 | 78 | expect(res.description.length).toBeGreaterThan(0); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /mobile-app/lib/ui/views/learn/superblock/superblock_viewmodel.dart: -------------------------------------------------------------------------------- 1 | import 'package:dio/dio.dart'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:freecodecamp/app/app.locator.dart'; 4 | import 'package:freecodecamp/models/learn/curriculum_model.dart'; 5 | import 'package:freecodecamp/service/authentication/authentication_service.dart'; 6 | import 'package:freecodecamp/service/dio_service.dart'; 7 | import 'package:freecodecamp/service/learn/learn_offline_service.dart'; 8 | import 'package:freecodecamp/service/learn/learn_service.dart'; 9 | import 'package:shared_preferences/shared_preferences.dart'; 10 | import 'package:stacked/stacked.dart'; 11 | 12 | class SuperBlockViewModel extends BaseViewModel { 13 | final _learnOfflineService = locator(); 14 | LearnOfflineService get learnOfflineService => _learnOfflineService; 15 | 16 | final _learnService = locator(); 17 | LearnService get learnService => _learnService; 18 | 19 | final AuthenticationService _auth = locator(); 20 | AuthenticationService get auth => _auth; 21 | 22 | final _dio = DioService.dio; 23 | 24 | double getPaddingBetweenBlocks(Block block) { 25 | if (block.isStepBased) { 26 | return 3.0; 27 | } 28 | 29 | if (block.dashedName == 'es6') { 30 | return 0; 31 | } 32 | 33 | return 50.0; 34 | } 35 | 36 | EdgeInsets getPaddingBeginAndEnd(int index, int challenges) { 37 | if (index == 0) { 38 | return const EdgeInsets.only(top: 16); 39 | } else if (challenges == 1) { 40 | return const EdgeInsets.only(bottom: 32); 41 | } else { 42 | return const EdgeInsets.all(0); 43 | } 44 | } 45 | 46 | Future getBlockOpenState(Block block) async { 47 | SharedPreferences prefs = await SharedPreferences.getInstance(); 48 | 49 | return prefs.getBool(block.name) ?? block.order == 0; 50 | } 51 | 52 | Future getSuperBlockData( 53 | String dashedName, 54 | String name, 55 | bool hasInternet, 56 | ) async { 57 | String baseUrl = LearnService.baseUrl; 58 | 59 | if (!hasInternet) { 60 | return SuperBlock( 61 | dashedName: dashedName, 62 | name: name, 63 | blocks: await _learnOfflineService.getCachedBlocks( 64 | dashedName, 65 | ), 66 | ); 67 | } 68 | 69 | final Response res = await _dio.get('$baseUrl/$dashedName.json'); 70 | 71 | if (res.statusCode == 200) { 72 | return SuperBlock.fromJson( 73 | res.data, 74 | dashedName, 75 | name, 76 | ); 77 | } else { 78 | throw Exception(res.data); 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /mobile-app/lib/ui/views/learn/widgets/console/console_view.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_inappwebview/flutter_inappwebview.dart'; 3 | import 'package:freecodecamp/ui/views/learn/widgets/console/console_viewmodel.dart'; 4 | import 'package:freecodecamp/ui/views/news/html_handler/html_handler.dart'; 5 | import 'package:stacked/stacked.dart'; 6 | 7 | class JavaScriptConsole extends StatelessWidget { 8 | const JavaScriptConsole({Key? key, required this.messages}) : super(key: key); 9 | 10 | final List messages; 11 | 12 | static String defaultMessage = ''' 13 | /** 14 | * Your test output will go here 15 | */ 16 | '''; 17 | 18 | @override 19 | Widget build(BuildContext context) { 20 | return ViewModelBuilder.reactive( 21 | viewModelBuilder: () => JavaScriptConsoleViewModel(), 22 | builder: (context, model, child) { 23 | HTMLParser parser = HTMLParser( 24 | context: context, 25 | ); 26 | 27 | return Expanded( 28 | child: Row( 29 | children: [ 30 | Expanded( 31 | child: Scrollbar( 32 | child: ListView.builder( 33 | padding: const EdgeInsets.all(8), 34 | physics: const ClampingScrollPhysics(), 35 | itemCount: messages.isEmpty ? 1 : messages.length, 36 | itemBuilder: (context, index) { 37 | List htmlWidgets = parser.parse( 38 | messages.isEmpty 39 | ? defaultMessage 40 | : messages[index].message, 41 | ); 42 | 43 | return consoleMessage(htmlWidgets, model, context); 44 | }, 45 | ), 46 | ), 47 | ), 48 | ], 49 | ), 50 | ); 51 | }, 52 | ); 53 | } 54 | 55 | Widget consoleMessage( 56 | List htmlWidgets, 57 | JavaScriptConsoleViewModel model, 58 | BuildContext context, 59 | ) { 60 | return messages.isEmpty 61 | ? Text( 62 | defaultMessage, 63 | style: TextStyle( 64 | color: model.getConsoleTextColor( 65 | ConsoleMessageLevel.LOG, 66 | ), 67 | ), 68 | ) 69 | : Row( 70 | children: [ 71 | for (int i = 0; i < htmlWidgets.length; i++) 72 | Expanded( 73 | child: htmlWidgets[i], 74 | ) 75 | ], 76 | ); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /mobile-app/lib/service/podcast/download_service.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:developer' as dev; 3 | import 'dart:io'; 4 | 5 | import 'package:dio/dio.dart'; 6 | import 'package:freecodecamp/app/app.locator.dart'; 7 | import 'package:freecodecamp/models/podcasts/episodes_model.dart'; 8 | import 'package:freecodecamp/models/podcasts/podcasts_model.dart'; 9 | import 'package:freecodecamp/service/dio_service.dart'; 10 | import 'package:freecodecamp/service/podcast/notification_service.dart'; 11 | import 'package:freecodecamp/service/podcast/podcasts_service.dart'; 12 | import 'package:path_provider/path_provider.dart'; 13 | import 'package:ua_client_hints/ua_client_hints.dart'; 14 | 15 | class DownloadService { 16 | final Dio dio = DioService.dio; 17 | static final DownloadService _downloadService = DownloadService._internal(); 18 | final _databaseService = locator(); 19 | final _notificationService = locator(); 20 | 21 | static final StreamController _progStream = 22 | StreamController.broadcast(); 23 | StreamController get progStream => _progStream; 24 | 25 | final Stream _porgress = _progStream.stream; 26 | Stream get progress => _porgress; 27 | 28 | static final StreamController _downloading = 29 | StreamController.broadcast(); 30 | 31 | final Stream _downloadingStream = _downloading.stream; 32 | Stream get downloadingStream => _downloadingStream; 33 | 34 | set setDownloadId(String state) { 35 | _downloadId = state; 36 | } 37 | 38 | String _downloadId = ''; 39 | String get downloadId => _downloadId; 40 | 41 | factory DownloadService() { 42 | return _downloadService; 43 | } 44 | 45 | void download(Episodes episode, Podcasts podcast) async { 46 | Directory app = await getApplicationDocumentsDirectory(); 47 | dev.log(_downloadId); 48 | _downloading.sink.add(true); 49 | 50 | String path = '${app.path}/episodes/${podcast.id}/${episode.id}.mp3'; 51 | 52 | await dio.download(episode.contentUrl!, path, 53 | onReceiveProgress: (int recevied, int total) { 54 | _progStream.sink.add(((recevied / total) * 100).toStringAsFixed(0)); 55 | }, options: Options(headers: {'User-Agent': await userAgent()})); 56 | _downloading.sink.add(false); 57 | setDownloadId = ''; 58 | _progStream.sink.add(''); 59 | await _notificationService.showNotification( 60 | 'Download complete', 61 | '${podcast.title} - ${episode.title}', 62 | ); 63 | await _databaseService.addPodcast(podcast); 64 | await _databaseService.addEpisode(episode); 65 | } 66 | 67 | DownloadService._internal(); 68 | } 69 | -------------------------------------------------------------------------------- /mobile-app/lib/service/podcast/notification_service.dart: -------------------------------------------------------------------------------- 1 | import 'dart:developer' as dev; 2 | import 'dart:math'; 3 | 4 | import 'package:flutter_local_notifications/flutter_local_notifications.dart'; 5 | 6 | // This is a singleton class and initialized only once 7 | class NotificationService { 8 | static final NotificationService _notificationService = 9 | NotificationService._internal(); 10 | final FlutterLocalNotificationsPlugin _flutterLocalNotificationsPlugin = 11 | FlutterLocalNotificationsPlugin(); 12 | Random random = Random(); 13 | 14 | factory NotificationService() { 15 | return _notificationService; 16 | } 17 | 18 | Future init() async { 19 | const AndroidInitializationSettings androidInitializationSettings = 20 | AndroidInitializationSettings('@mipmap/launcher_icon'); 21 | final DarwinInitializationSettings iosInitializationSettings = 22 | DarwinInitializationSettings(onDidReceiveLocalNotification: ( 23 | int id, 24 | String? title, 25 | String? body, 26 | String? payload, 27 | ) async { 28 | dev.log('onDidReceiveLocalNotification: $id, $title, $body, $payload'); 29 | }); 30 | 31 | final InitializationSettings initializationSettings = 32 | InitializationSettings( 33 | android: androidInitializationSettings, 34 | iOS: iosInitializationSettings, 35 | ); 36 | 37 | await _flutterLocalNotificationsPlugin 38 | .resolvePlatformSpecificImplementation< 39 | AndroidFlutterLocalNotificationsPlugin>() 40 | ?.requestNotificationsPermission(); 41 | 42 | await _flutterLocalNotificationsPlugin.initialize(initializationSettings); 43 | } 44 | 45 | Future showNotification(String title, String body) async { 46 | // Check up more on the below values here 47 | const AndroidNotificationDetails androidNotificationDetails = 48 | AndroidNotificationDetails( 49 | 'fcc-notif-channel', 50 | 'podcast-episodes', 51 | channelDescription: 'Channel description', 52 | priority: Priority.high, 53 | importance: Importance.max, 54 | ); 55 | 56 | const DarwinNotificationDetails iosNotificationDetails = 57 | DarwinNotificationDetails(threadIdentifier: 'fcc-ios-notif-channel'); 58 | 59 | const NotificationDetails platformChannelSpecifics = NotificationDetails( 60 | android: androidNotificationDetails, 61 | iOS: iosNotificationDetails, 62 | ); 63 | 64 | await _flutterLocalNotificationsPlugin.show( 65 | random.nextInt(pow(2, 31).toInt() - 1), 66 | title, 67 | body, 68 | platformChannelSpecifics, 69 | ); 70 | } 71 | 72 | NotificationService._internal(); 73 | } 74 | -------------------------------------------------------------------------------- /.github/workflows/mobile-curriculum-e2e.yml: -------------------------------------------------------------------------------- 1 | name: CI - Mobile curriculum test 2 | 3 | on: 4 | workflow_dispatch: 5 | pull_request: 6 | branches: 7 | - main 8 | paths: 9 | - "mobile-app/**" 10 | push: 11 | branches: 12 | - main 13 | schedule: 14 | - cron: "0 0 * * 0" 15 | 16 | jobs: 17 | mobile-test: 18 | name: Test curriculum for mobile app 19 | runs-on: ubuntu-22.04 20 | strategy: 21 | matrix: 22 | node-version: [20.x] 23 | 24 | steps: 25 | - name: Checkout freeCodeCamp main repo 26 | uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4 27 | with: 28 | repository: freeCodeCamp/freeCodeCamp 29 | 30 | - name: Checkout mobile repo 31 | uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4 32 | with: 33 | path: mobile 34 | 35 | - name: Setup pnpm 36 | uses: pnpm/action-setup@v2 37 | with: 38 | version: 9 39 | 40 | - name: Use Node.js ${{ matrix.node-version }} 41 | uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4 42 | with: 43 | node-version: ${{ matrix.node-version }} 44 | cache: pnpm 45 | 46 | - name: Setup Flutter 3.24.x 47 | uses: subosito/flutter-action@44ac965b96f18d999802d4b807e3256d5a3f9fa1 # v2 48 | with: 49 | flutter-version: "3.24.x" 50 | channel: "stable" 51 | cache: true 52 | cache-key: flutter-3.24.x 53 | cache-path: ${{ runner.tool_cache }}/flutter 54 | 55 | - name: Set freeCodeCamp Environment Variables 56 | run: cp sample.env .env 57 | 58 | - name: Install and Build 59 | run: | 60 | pnpm install 61 | pnpm run create:shared 62 | pnpm run build:curriculum 63 | 64 | - name: Generate mobile test files 65 | run: | 66 | cd mobile/mobile-app 67 | echo "DEVELOPMENTMODE=true" > .env 68 | echo "HASHNODE_PUBLICATION_ID=$HASHNODE_PUBLICATION_ID" > .env 69 | flutter pub get 70 | flutter test test/widget_test.dart 71 | 72 | - name: Install playwright dependencies 73 | run: npx playwright install --with-deps 74 | 75 | - name: Install serve 76 | run: npm install -g serve 77 | 78 | - name: Run playwright tests 79 | run: npx playwright test --config=playwright-mobile.config.ts 80 | 81 | - uses: actions/upload-artifact@65462800fd760344b1a7b4382951275a0abb4808 # v4 82 | if: ${{ !cancelled() }} 83 | with: 84 | name: playwright-report-mobile 85 | path: playwright/reporter 86 | retention-days: 30 87 | -------------------------------------------------------------------------------- /mobile-app/lib/ui/widgets/tag_widget.dart: -------------------------------------------------------------------------------- 1 | import 'dart:math'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:freecodecamp/app/app.locator.dart'; 5 | import 'package:freecodecamp/app/app.router.dart'; 6 | import 'package:stacked_services/stacked_services.dart'; 7 | 8 | class TagButton extends StatefulWidget { 9 | const TagButton({ 10 | Key? key, 11 | required this.tagName, 12 | required this.tagSlug, 13 | }) : super(key: key); 14 | 15 | final String tagName; 16 | final String tagSlug; 17 | 18 | static Color randomColor() { 19 | var randomNum = Random(); 20 | 21 | List colors = [ 22 | const Color.fromRGBO(0xdb, 0xb8, 0xff, 1), 23 | const Color.fromRGBO(0xf1, 0xbe, 0x32, 1), 24 | const Color.fromRGBO(0x99, 0xc9, 0xff, 1), 25 | const Color.fromRGBO(0xac, 0xd1, 0x57, 1) 26 | ]; 27 | 28 | return colors[randomNum.nextInt(4)]; 29 | } 30 | 31 | @override 32 | State createState() => _TagButtonState(); 33 | } 34 | 35 | class _TagButtonState extends State 36 | with AutomaticKeepAliveClientMixin { 37 | final _navigationService = locator(); 38 | final _tagColor = TagButton.randomColor(); 39 | @override 40 | // ignore: must_call_super 41 | Widget build(BuildContext context) { 42 | return Padding( 43 | padding: const EdgeInsets.fromLTRB(0, 8, 8, 0), 44 | child: InkWell( 45 | onTap: () { 46 | _navigationService.navigateTo( 47 | Routes.newsFeedView, 48 | arguments: NewsFeedViewArguments( 49 | tagSlug: widget.tagSlug, 50 | fromTag: true, 51 | subject: widget.tagName, 52 | ), 53 | ); 54 | }, 55 | child: Container( 56 | constraints: BoxConstraints( 57 | maxWidth: MediaQuery.of(context).size.width * 0.45), 58 | decoration: ShapeDecoration( 59 | color: _tagColor, 60 | shape: const StadiumBorder(), 61 | ), 62 | child: Padding( 63 | padding: const EdgeInsets.symmetric( 64 | vertical: 4, 65 | horizontal: 8, 66 | ), 67 | child: Tooltip( 68 | message: '#${widget.tagName}', 69 | child: Text( 70 | '#${widget.tagName}', 71 | maxLines: 1, 72 | overflow: TextOverflow.ellipsis, 73 | style: const TextStyle( 74 | fontSize: 16, 75 | color: Colors.black, 76 | ), 77 | ), 78 | ), 79 | ), 80 | ), 81 | ), 82 | ); 83 | } 84 | 85 | @override 86 | bool get wantKeepAlive => true; 87 | } 88 | -------------------------------------------------------------------------------- /mobile-app/integration_test_runner.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | void main(List args) { 4 | exitCode = 0; 5 | 6 | bool isMacOS = false; 7 | final testResults = []; 8 | int errorCount = 0; 9 | 10 | if (args.contains('--ios')) { 11 | isMacOS = true; 12 | 13 | final result = Process.runSync('which', ['applesimutils']); 14 | if (result.exitCode != 0) { 15 | print(''' 16 | ------------------------------------------ 17 | WARNING: applesimutils is not installed 18 | ------------------------------------------ 19 | You can install it using homebrew: 20 | 21 | brew tap wix/brew 22 | brew install applesimutils 23 | ------------------------------------------'''); 24 | exit(1); 25 | } 26 | } 27 | 28 | final testFilePaths = Directory('integration_test') 29 | .listSync(recursive: true) 30 | .whereType() 31 | .map((file) => file.path) 32 | .toList(); 33 | 34 | for (final testFile in testFilePaths) { 35 | if (isMacOS) { 36 | final result = Process.runSync( 37 | 'applesimutils', 38 | [ 39 | '--byName', 40 | 'iPhone 15 Pro Max', 41 | '--bundle', 42 | 'org.freecodecamp.ios', 43 | '--setPermissions', 44 | 'notifications=YES' 45 | ], 46 | runInShell: true); 47 | if (result.exitCode != 0) { 48 | print('Test failed: $testFile'); 49 | print(result.stdout); 50 | print(result.stderr); 51 | exit(1); 52 | } 53 | } 54 | 55 | print('Testing: $testFile'); 56 | 57 | final result = Process.runSync( 58 | 'flutter', 59 | [ 60 | 'drive', 61 | '--no-pub', 62 | '--driver=test_driver/integration_test.dart', 63 | '--target=$testFile', 64 | ], 65 | runInShell: true); 66 | 67 | print(result.stdout); 68 | 69 | if (result.exitCode != 0) { 70 | print('Test failed: $testFile\n'); 71 | testResults.add('$testFile\n\n${result.stdout}'); 72 | errorCount++; 73 | exitCode = 1; 74 | } 75 | } 76 | 77 | if (errorCount > 0) { 78 | final errorFile = File('screenshots/errors.txt'); 79 | errorFile.createSync(recursive: true); 80 | errorFile.writeAsStringSync(testResults.join('\n\n')); 81 | } 82 | 83 | print('------------------------------------------'); 84 | print('Test summary:'); 85 | print('------------------------------------------'); 86 | print('Total: ${testFilePaths.length}'); 87 | print('Passed: ${testFilePaths.length - errorCount}'); 88 | print('Failed: $errorCount\n'); 89 | print('Failed tests output is in screenshots/errors.txt'); 90 | print('------------------------------------------'); 91 | exit(exitCode); 92 | } 93 | -------------------------------------------------------------------------------- /mobile-app/lib/ui/views/news/news-search/news_search_viewmodel.dart: -------------------------------------------------------------------------------- 1 | import 'dart:io'; 2 | 3 | import 'package:algolia_helper_flutter/algolia_helper_flutter.dart'; 4 | import 'package:flutter/cupertino.dart'; 5 | import 'package:flutter_dotenv/flutter_dotenv.dart'; 6 | import 'package:freecodecamp/app/app.locator.dart'; 7 | import 'package:freecodecamp/app/app.router.dart'; 8 | import 'package:freecodecamp/constants/radio_articles.dart'; 9 | import 'package:stacked/stacked.dart'; 10 | import 'package:stacked_services/stacked_services.dart'; 11 | 12 | class NewsSearchModel extends BaseViewModel { 13 | String _searchTerm = ''; 14 | String get getSearchTerm => _searchTerm; 15 | 16 | bool _hasData = false; 17 | bool get hasData => _hasData; 18 | 19 | bool _isLoading = true; 20 | bool get isLoading => _isLoading; 21 | 22 | List currentResult = []; 23 | 24 | final searchbarController = TextEditingController(); 25 | final _navigationService = locator(); 26 | 27 | final algolia = HitsSearcher( 28 | applicationID: dotenv.get('ALGOLIAAPPID'), 29 | apiKey: dotenv.get('ALGOLIAKEY'), 30 | indexName: 'news', 31 | ); 32 | 33 | set setHasData(value) { 34 | _hasData = value; 35 | notifyListeners(); 36 | } 37 | 38 | set setIsLoading(value) { 39 | _isLoading = value; 40 | notifyListeners(); 41 | } 42 | 43 | void init() { 44 | algolia.query('JavaScript'); 45 | algolia.responses.listen((res) { 46 | // Remove radio articles from search on iOS 47 | if (Platform.isIOS) { 48 | res.hits.removeWhere( 49 | (element) => radioArticles.contains(element['objectID'])); 50 | } 51 | 52 | currentResult = res.hits; 53 | _hasData = res.hits.isNotEmpty; 54 | _isLoading = false; 55 | notifyListeners(); 56 | }); 57 | } 58 | 59 | void onDispose() { 60 | algolia.dispose(); 61 | searchbarController.dispose(); 62 | } 63 | 64 | void setSearchTerm(value) { 65 | _searchTerm = value; 66 | algolia.query( 67 | value.isEmpty ? 'JavaScript' : value, 68 | ); 69 | notifyListeners(); 70 | } 71 | 72 | // TODO: Add search results feed back post-migration 73 | // void searchSubject() { 74 | // _navigationService.navigateTo( 75 | // Routes.newsFeedView, 76 | // arguments: NewsFeedViewArguments( 77 | // fromSearch: true, 78 | // tutorials: currentResult, 79 | // subject: _searchTerm == '' ? 'JavaScript' : _searchTerm, 80 | // ), 81 | // ); 82 | // } 83 | 84 | void navigateToTutorial(String id, String title) { 85 | _navigationService.navigateTo( 86 | Routes.newsTutorialView, 87 | arguments: NewsTutorialViewArguments( 88 | refId: id, 89 | title: title, 90 | ), 91 | ); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /mobile-app/lib/firebase_options.dart: -------------------------------------------------------------------------------- 1 | // File generated by FlutterFire CLI. 2 | // ignore_for_file: lines_longer_than_80_chars, avoid_classes_with_only_static_members 3 | import 'package:firebase_core/firebase_core.dart' show FirebaseOptions; 4 | import 'package:flutter/foundation.dart' 5 | show defaultTargetPlatform, kIsWeb, TargetPlatform; 6 | 7 | /// Default [FirebaseOptions] for use with your Firebase apps. 8 | /// 9 | /// Example: 10 | /// ```dart 11 | /// import 'firebase_options.dart'; 12 | /// // ... 13 | /// await Firebase.initializeApp( 14 | /// options: DefaultFirebaseOptions.currentPlatform, 15 | /// ); 16 | /// ``` 17 | class DefaultFirebaseOptions { 18 | static FirebaseOptions get currentPlatform { 19 | if (kIsWeb) { 20 | throw UnsupportedError( 21 | 'DefaultFirebaseOptions have not been configured for web - ' 22 | 'you can reconfigure this by running the FlutterFire CLI again.', 23 | ); 24 | } 25 | switch (defaultTargetPlatform) { 26 | case TargetPlatform.android: 27 | return android; 28 | case TargetPlatform.iOS: 29 | return ios; 30 | case TargetPlatform.macOS: 31 | throw UnsupportedError( 32 | 'DefaultFirebaseOptions have not been configured for macos - ' 33 | 'you can reconfigure this by running the FlutterFire CLI again.', 34 | ); 35 | case TargetPlatform.windows: 36 | throw UnsupportedError( 37 | 'DefaultFirebaseOptions have not been configured for windows - ' 38 | 'you can reconfigure this by running the FlutterFire CLI again.', 39 | ); 40 | case TargetPlatform.linux: 41 | throw UnsupportedError( 42 | 'DefaultFirebaseOptions have not been configured for linux - ' 43 | 'you can reconfigure this by running the FlutterFire CLI again.', 44 | ); 45 | default: 46 | throw UnsupportedError( 47 | 'DefaultFirebaseOptions are not supported for this platform.', 48 | ); 49 | } 50 | } 51 | 52 | static const FirebaseOptions android = FirebaseOptions( 53 | apiKey: 'AIzaSyCP3yAWau0JJE8BPgsUrbT2K27EJX3z2KE', 54 | appId: '1:363152234407:android:6293f9873ae6df8a738882', 55 | messagingSenderId: '363152234407', 56 | projectId: 'mobile-4ee8a', 57 | storageBucket: 'mobile-4ee8a.appspot.com', 58 | ); 59 | 60 | static const FirebaseOptions ios = FirebaseOptions( 61 | apiKey: 'AIzaSyCFcTnIuxW7AeskmoNwbrEZX_ZvYz-aAdw', 62 | appId: '1:363152234407:ios:17273b86fa3ff2e4738882', 63 | messagingSenderId: '363152234407', 64 | projectId: 'mobile-4ee8a', 65 | storageBucket: 'mobile-4ee8a.appspot.com', 66 | iosClientId: '363152234407-qb25f7aak3egr8iod13bhsltjgf8viuq.apps.googleusercontent.com', 67 | iosBundleId: 'org.freecodecamp.ios', 68 | ); 69 | } 70 | -------------------------------------------------------------------------------- /mobile-app/lib/ui/views/login/native_login_viewmodel.dart: -------------------------------------------------------------------------------- 1 | import 'package:dio/dio.dart'; 2 | import 'package:flutter/cupertino.dart'; 3 | import 'package:flutter_dotenv/flutter_dotenv.dart'; 4 | import 'package:freecodecamp/app/app.locator.dart'; 5 | import 'package:freecodecamp/service/authentication/authentication_service.dart'; 6 | import 'package:freecodecamp/service/developer_service.dart'; 7 | import 'package:freecodecamp/service/dio_service.dart'; 8 | import 'package:stacked/stacked.dart'; 9 | 10 | class NativeLoginViewModel extends BaseViewModel { 11 | TextEditingController emailController = TextEditingController(); 12 | TextEditingController otpController = TextEditingController(); 13 | bool showOTPfield = false; 14 | bool incorrectOTP = false; 15 | final Dio _dio = DioService.dio; 16 | 17 | final AuthenticationService auth = locator(); 18 | final DeveloperService developerService = locator(); 19 | 20 | bool _emailFieldIsValid = false; 21 | bool get emailFieldIsValid => _emailFieldIsValid; 22 | 23 | bool _otpFieldIsValid = false; 24 | bool get otpFieldIsValid => _otpFieldIsValid; 25 | 26 | set emailFieldIsValid(bool value) { 27 | _emailFieldIsValid = value; 28 | notifyListeners(); 29 | } 30 | 31 | set otpFieldIsValid(bool value) { 32 | _otpFieldIsValid = value; 33 | notifyListeners(); 34 | } 35 | 36 | void init() async { 37 | bool isEmail(String em) { 38 | String p = 39 | r'^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$'; 40 | 41 | RegExp regExp = RegExp(p); 42 | 43 | return regExp.hasMatch(em); 44 | } 45 | 46 | emailController.addListener(() { 47 | if (isEmail(emailController.text)) { 48 | emailFieldIsValid = true; 49 | } else if (emailFieldIsValid) { 50 | emailFieldIsValid = false; 51 | } 52 | }); 53 | 54 | otpController.addListener(() { 55 | if (RegExp(r'^[0-9]{6}$').hasMatch(otpController.text)) { 56 | otpFieldIsValid = true; 57 | } else if (emailFieldIsValid) { 58 | otpFieldIsValid = false; 59 | } 60 | }); 61 | } 62 | 63 | void sendOTPtoEmail() async { 64 | showOTPfield = true; 65 | notifyListeners(); 66 | await dotenv.load(); 67 | await _dio.post( 68 | 'https://${dotenv.get('AUTH0_DOMAIN')}/passwordless/start', 69 | data: { 70 | 'client_id': dotenv.get('AUTH0_CLIENT_ID'), 71 | 'connection': 'email', 72 | 'email': emailController.text, 73 | 'send': 'code', 74 | }, 75 | ); 76 | } 77 | 78 | void verifyOTP(BuildContext context) async { 79 | await dotenv.load(); 80 | bool isSuccess = await auth.login( 81 | context, 82 | 'email', 83 | email: emailController.text, 84 | otp: otpController.text, 85 | ); 86 | if (isSuccess) { 87 | incorrectOTP = false; 88 | } else { 89 | incorrectOTP = true; 90 | } 91 | notifyListeners(); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /mobile-app/lib/ui/views/code_radio/code_radio_viewmodel.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:convert'; 3 | 4 | import 'package:freecodecamp/app/app.locator.dart'; 5 | import 'package:freecodecamp/models/code-radio/code_radio_model.dart'; 6 | import 'package:freecodecamp/service/audio/audio_service.dart'; 7 | import 'package:shared_preferences/shared_preferences.dart'; 8 | import 'package:stacked/stacked.dart'; 9 | import 'package:web_socket_channel/web_socket_channel.dart'; 10 | 11 | class CodeRadioViewModel extends BaseViewModel { 12 | final audioService = locator().audioHandler; 13 | bool stoppedManually = false; 14 | 15 | final _webSocketChannel = WebSocketChannel.connect(Uri.parse( 16 | 'wss://coderadio-admin-v2.freecodecamp.org/api/live/nowplaying/websocket')); 17 | final _webSocketController = StreamController.broadcast(); 18 | StreamController get webSocketController => _webSocketController; 19 | 20 | int _counter = 0; 21 | int get counter => _counter; 22 | 23 | final _audioStateController = StreamController(); 24 | StreamController get audioStateController => _audioStateController; 25 | 26 | Timer? _timer; 27 | Timer? get timer => _timer; 28 | 29 | void init() async { 30 | _webSocketChannel.sink.add(jsonEncode({ 31 | 'subs': {'station:coderadio': {}} 32 | })); 33 | await _webSocketController.addStream(_webSocketChannel.stream); 34 | } 35 | 36 | void startProgressBar(int timeElapsed, int duration) { 37 | _counter = timeElapsed; 38 | _timer = Timer.periodic(const Duration(seconds: 1), (timer) { 39 | _counter++; 40 | _audioStateController.sink.add(_counter); 41 | 42 | if (_counter == duration) { 43 | timer.cancel(); 44 | } 45 | }); 46 | } 47 | 48 | void pauseUnpauseRadio() async { 49 | if (!audioService.isPlaying('coderadio')) { 50 | stoppedManually = false; 51 | 52 | await audioService.play(); 53 | notifyListeners(); 54 | } else { 55 | stoppedManually = true; 56 | await audioService.pause(); 57 | notifyListeners(); 58 | } 59 | } 60 | 61 | Future setAndGetLastId(CodeRadio radio) async { 62 | SharedPreferences prefs = await SharedPreferences.getInstance(); 63 | 64 | if (prefs.getString('lastSongId') == null) { 65 | prefs.setString('lastSongId', radio.nowPlaying.id); 66 | } 67 | 68 | if (radio.nowPlaying.id != prefs.getString('lastSongId')) { 69 | setBackgroundWidget(radio); 70 | if (!stoppedManually) { 71 | audioService.play(); 72 | } 73 | prefs.setString('lastSongId', radio.nowPlaying.id); 74 | } 75 | } 76 | 77 | Future toggleRadio(CodeRadio radio) async { 78 | setBackgroundWidget(radio); 79 | audioService.play(); 80 | audioService.seek(Duration(seconds: radio.elapsed)); 81 | } 82 | 83 | Future setBackgroundWidget(CodeRadio radio) async { 84 | if (audioService.isPlaying('coderadio')) { 85 | audioService.stop(); 86 | } 87 | 88 | await audioService.codeRadioMusic(radio); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /mobile-app/lib/models/podcasts/episodes_model.dart: -------------------------------------------------------------------------------- 1 | // This class name might be changed in the future 2 | class Episodes { 3 | final String id; 4 | final String podcastId; 5 | final String title; 6 | final String? description; 7 | final DateTime? publicationDate; 8 | final String? contentUrl; 9 | final Duration? duration; 10 | 11 | Episodes({ 12 | required this.id, 13 | required this.podcastId, 14 | required this.title, 15 | this.description, 16 | this.publicationDate, 17 | this.contentUrl, 18 | this.duration, 19 | }); 20 | 21 | factory Episodes.fromAPIJson(Map json) => Episodes( 22 | id: json['_id'] as String, 23 | podcastId: json['podcastId'] as String, 24 | title: json['title'] as String, 25 | description: json['description'] as String?, 26 | publicationDate: json['publicationDate'] == null 27 | ? null 28 | : DateTime.parse(json['publicationDate']), 29 | contentUrl: json['audioUrl'] as String?, 30 | duration: 31 | json['duration'] == null ? null : parseDuration(json['duration']), 32 | ); 33 | 34 | factory Episodes.fromDBJson(Map json) => Episodes( 35 | id: json['id'] as String, 36 | podcastId: json['podcastId'] as String, 37 | title: json['title'] as String, 38 | description: json['description'] as String?, 39 | publicationDate: json['publicationDate'] == null 40 | ? null 41 | : DateTime.parse(json['publicationDate']), 42 | contentUrl: json['contentUrl'] as String?, 43 | duration: 44 | json['duration'] == null ? null : parseDuration(json['duration']), 45 | ); 46 | 47 | Map toJson() => { 48 | 'id': id, 49 | 'podcastId': podcastId, 50 | 'title': title, 51 | 'description': description, 52 | 'publicationDate': publicationDate?.toIso8601String(), 53 | 'contentUrl': contentUrl, 54 | 'duration': duration.toString(), 55 | }; 56 | 57 | @override 58 | String toString() { 59 | return '''Episodes { 60 | id: $id, 61 | podcastId: $podcastId, 62 | title: $title, 63 | description: ${description!.substring(0, 100)}, 64 | publicationDate: $publicationDate, 65 | contentUrl: $contentUrl, 66 | duration: $duration, 67 | }'''; 68 | } 69 | } 70 | 71 | Duration parseDuration(String s) { 72 | int hours = 0; 73 | int minutes = 0; 74 | int seconds = 0; 75 | int microsec = 0; 76 | List parts = s.split(':'); 77 | if (parts.length > 2) { 78 | hours = int.tryParse(parts[parts.length - 3]) ?? 0; 79 | } 80 | if (parts.length > 1) { 81 | minutes = int.tryParse(parts[parts.length - 2]) ?? 0; 82 | } 83 | List secondsParts = parts[parts.length - 1].split('.'); 84 | seconds = int.tryParse(secondsParts[0]) ?? 0; 85 | if (secondsParts.length > 1) { 86 | microsec = int.tryParse(secondsParts[1]) ?? 0; 87 | } 88 | return Duration( 89 | hours: hours, 90 | minutes: minutes, 91 | seconds: seconds, 92 | microseconds: microsec, 93 | ); 94 | } 95 | -------------------------------------------------------------------------------- /mobile-app/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "size" : "20x20", 5 | "idiom" : "iphone", 6 | "filename" : "Icon-App-20x20@2x.png", 7 | "scale" : "2x" 8 | }, 9 | { 10 | "size" : "20x20", 11 | "idiom" : "iphone", 12 | "filename" : "Icon-App-20x20@3x.png", 13 | "scale" : "3x" 14 | }, 15 | { 16 | "size" : "29x29", 17 | "idiom" : "iphone", 18 | "filename" : "Icon-App-29x29@1x.png", 19 | "scale" : "1x" 20 | }, 21 | { 22 | "size" : "29x29", 23 | "idiom" : "iphone", 24 | "filename" : "Icon-App-29x29@2x.png", 25 | "scale" : "2x" 26 | }, 27 | { 28 | "size" : "29x29", 29 | "idiom" : "iphone", 30 | "filename" : "Icon-App-29x29@3x.png", 31 | "scale" : "3x" 32 | }, 33 | { 34 | "size" : "40x40", 35 | "idiom" : "iphone", 36 | "filename" : "Icon-App-40x40@2x.png", 37 | "scale" : "2x" 38 | }, 39 | { 40 | "size" : "40x40", 41 | "idiom" : "iphone", 42 | "filename" : "Icon-App-40x40@3x.png", 43 | "scale" : "3x" 44 | }, 45 | { 46 | "size" : "60x60", 47 | "idiom" : "iphone", 48 | "filename" : "Icon-App-60x60@2x.png", 49 | "scale" : "2x" 50 | }, 51 | { 52 | "size" : "60x60", 53 | "idiom" : "iphone", 54 | "filename" : "Icon-App-60x60@3x.png", 55 | "scale" : "3x" 56 | }, 57 | { 58 | "size" : "20x20", 59 | "idiom" : "ipad", 60 | "filename" : "Icon-App-20x20@1x.png", 61 | "scale" : "1x" 62 | }, 63 | { 64 | "size" : "20x20", 65 | "idiom" : "ipad", 66 | "filename" : "Icon-App-20x20@2x.png", 67 | "scale" : "2x" 68 | }, 69 | { 70 | "size" : "29x29", 71 | "idiom" : "ipad", 72 | "filename" : "Icon-App-29x29@1x.png", 73 | "scale" : "1x" 74 | }, 75 | { 76 | "size" : "29x29", 77 | "idiom" : "ipad", 78 | "filename" : "Icon-App-29x29@2x.png", 79 | "scale" : "2x" 80 | }, 81 | { 82 | "size" : "40x40", 83 | "idiom" : "ipad", 84 | "filename" : "Icon-App-40x40@1x.png", 85 | "scale" : "1x" 86 | }, 87 | { 88 | "size" : "40x40", 89 | "idiom" : "ipad", 90 | "filename" : "Icon-App-40x40@2x.png", 91 | "scale" : "2x" 92 | }, 93 | { 94 | "size" : "76x76", 95 | "idiom" : "ipad", 96 | "filename" : "Icon-App-76x76@1x.png", 97 | "scale" : "1x" 98 | }, 99 | { 100 | "size" : "76x76", 101 | "idiom" : "ipad", 102 | "filename" : "Icon-App-76x76@2x.png", 103 | "scale" : "2x" 104 | }, 105 | { 106 | "size" : "83.5x83.5", 107 | "idiom" : "ipad", 108 | "filename" : "Icon-App-83.5x83.5@2x.png", 109 | "scale" : "2x" 110 | }, 111 | { 112 | "size" : "1024x1024", 113 | "idiom" : "ios-marketing", 114 | "filename" : "Icon-App-1024x1024@1x.png", 115 | "scale" : "1x" 116 | } 117 | ], 118 | "info" : { 119 | "version" : 1, 120 | "author" : "xcode" 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /mobile-app/lib/app/app.locator.dart: -------------------------------------------------------------------------------- 1 | // GENERATED CODE - DO NOT MODIFY BY HAND 2 | 3 | // ************************************************************************** 4 | // StackedLocatorGenerator 5 | // ************************************************************************** 6 | 7 | // ignore_for_file: public_member_api_docs, implementation_imports, depend_on_referenced_packages 8 | 9 | import 'package:sqflite_migration_service/src/database_migration_service.dart'; 10 | import 'package:stacked_services/src/dialog/dialog_service.dart'; 11 | import 'package:stacked_services/src/navigation/navigation_service.dart'; 12 | import 'package:stacked_services/src/snackbar/snackbar_service.dart'; 13 | import 'package:stacked_shared/stacked_shared.dart'; 14 | 15 | import '../service/audio/audio_service.dart'; 16 | import '../service/authentication/authentication_service.dart'; 17 | import '../service/developer_service.dart'; 18 | import '../service/dio_service.dart'; 19 | import '../service/firebase/analytics_service.dart'; 20 | import '../service/firebase/remote_config_service.dart'; 21 | import '../service/learn/learn_file_service.dart'; 22 | import '../service/learn/learn_offline_service.dart'; 23 | import '../service/learn/learn_service.dart'; 24 | import '../service/locale_service.dart'; 25 | import '../service/navigation/quick_actions_service.dart'; 26 | import '../service/news/api_service.dart'; 27 | import '../service/news/bookmark_service.dart'; 28 | import '../service/podcast/download_service.dart'; 29 | import '../service/podcast/notification_service.dart'; 30 | import '../service/podcast/podcasts_service.dart'; 31 | 32 | final locator = StackedLocator.instance; 33 | 34 | Future setupLocator({ 35 | String? environment, 36 | EnvironmentFilter? environmentFilter, 37 | }) async { 38 | // Register environments 39 | locator.registerEnvironment( 40 | environment: environment, environmentFilter: environmentFilter); 41 | 42 | // Register dependencies 43 | locator.registerLazySingleton(() => NavigationService()); 44 | locator.registerLazySingleton(() => DialogService()); 45 | locator.registerLazySingleton(() => SnackbarService()); 46 | locator.registerLazySingleton(() => DatabaseMigrationService()); 47 | locator.registerLazySingleton(() => PodcastsDatabaseService()); 48 | locator.registerLazySingleton(() => NotificationService()); 49 | locator.registerLazySingleton(() => DeveloperService()); 50 | locator.registerLazySingleton(() => AuthenticationService()); 51 | locator.registerLazySingleton(() => AppAudioService()); 52 | locator.registerLazySingleton(() => DownloadService()); 53 | locator.registerLazySingleton(() => LearnService()); 54 | locator.registerLazySingleton(() => LearnFileService()); 55 | locator.registerLazySingleton(() => LearnOfflineService()); 56 | locator.registerLazySingleton(() => QuickActionsService()); 57 | locator.registerLazySingleton(() => AnalyticsService()); 58 | locator.registerLazySingleton(() => RemoteConfigService()); 59 | locator.registerLazySingleton(() => BookmarksDatabaseService()); 60 | locator.registerLazySingleton(() => LocaleService()); 61 | locator.registerLazySingleton(() => DioService()); 62 | locator.registerLazySingleton(() => NewsApiServive()); 63 | } 64 | -------------------------------------------------------------------------------- /mobile-app/lib/ui/views/podcast/episode-list/episode_list_viewmodel.dart: -------------------------------------------------------------------------------- 1 | import 'package:freecodecamp/app/app.locator.dart'; 2 | import 'package:freecodecamp/models/podcasts/episodes_model.dart'; 3 | import 'package:freecodecamp/models/podcasts/podcasts_model.dart'; 4 | import 'package:freecodecamp/service/developer_service.dart'; 5 | import 'package:freecodecamp/service/dio_service.dart'; 6 | import 'package:freecodecamp/service/podcast/podcasts_service.dart'; 7 | import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; 8 | import 'package:stacked/stacked.dart'; 9 | 10 | class EpisodeListViewModel extends BaseViewModel { 11 | EpisodeListViewModel(this.podcast); 12 | 13 | final _databaseService = locator(); 14 | final _developerService = locator(); 15 | final Podcasts podcast; 16 | int epsLength = 0; 17 | Object? _activeCallbackIdentity; 18 | final _dio = DioService.dio; 19 | 20 | late Future> _episodes; 21 | Future> get episodes => _episodes; 22 | 23 | bool _showMoreDescription = false; 24 | bool get showDescription => _showMoreDescription; 25 | 26 | set setShowMoreDescription(bool state) { 27 | _showMoreDescription = state; 28 | notifyListeners(); 29 | } 30 | 31 | final PagingController _pagingController = PagingController( 32 | firstPageKey: 0, 33 | ); 34 | PagingController get pagingController => _pagingController; 35 | 36 | void initState(bool isDownloadView) async { 37 | _databaseService.initialise(); 38 | if (isDownloadView) { 39 | _episodes = _databaseService.getEpisodes(podcast); 40 | epsLength = (await episodes).length; 41 | } else { 42 | _pagingController.addPageRequestListener((pageKey) { 43 | fetchEpisodes(podcast.id, pageKey); 44 | }); 45 | } 46 | notifyListeners(); 47 | } 48 | 49 | void fetchEpisodes(String podcastId, [int pageKey = 0]) async { 50 | final callbackIdentity = Object(); // TODO: What's this doing or used for? 51 | _activeCallbackIdentity = callbackIdentity; 52 | String baseUrl = (await _developerService.developmentMode()) 53 | ? 'https://api.mobile.freecodecamp.dev/' 54 | : 'https://api.mobile.freecodecamp.org/'; 55 | try { 56 | final res = await _dio.get( 57 | '${baseUrl}podcasts/$podcastId/episodes?page=$pageKey', 58 | ); 59 | if (callbackIdentity == _activeCallbackIdentity) { 60 | final List episodes = res.data['episodes']; 61 | epsLength = res.data['podcast']['numOfEps']; 62 | notifyListeners(); 63 | final List eps = 64 | episodes.map((e) => Episodes.fromAPIJson(e)).toList(); 65 | final prevCount = _pagingController.itemList?.length ?? 0; 66 | if (prevCount + 20 >= epsLength) { 67 | _pagingController.appendLastPage(eps); 68 | } else { 69 | _pagingController.appendPage(eps, pageKey + 1); 70 | } 71 | } 72 | } catch (e) { 73 | if (callbackIdentity == _activeCallbackIdentity) { 74 | _pagingController.error = e; 75 | } 76 | } 77 | } 78 | 79 | @override 80 | void dispose() { 81 | _pagingController.dispose(); 82 | _activeCallbackIdentity = null; 83 | super.dispose(); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /mobile-app/pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: freecodecamp 2 | description: freecodecamp.org app. 3 | publish_to: none 4 | version: 5.0.0+50000 5 | environment: 6 | sdk: ">=3.0.0 <4.0.0" 7 | dependencies: 8 | algolia_helper_flutter: ^1.0.0 9 | audio_service: ^0.18.13 10 | # NOTE: Versions 1.5.0 and above support iOS 13+ only 11 | auth0_flutter: <=1.4.1 12 | cached_network_image: ^3.3.1 13 | curl_logger_dio_interceptor: ^1.0.0 # NOTE: Do we still want this? 14 | device_info_plus: ^9.1.2 # TODO: upgrade after migrating to FLutter 3.19 15 | dio: ^5.4.3+1 16 | firebase_analytics: ^11.3.3 17 | firebase_core: ^3.7.0 18 | firebase_crashlytics: ^4.1.3 19 | firebase_remote_config: ^5.1.3 20 | flutter: 21 | sdk: flutter 22 | flutter_custom_tabs: ^2.0.0 23 | flutter_dotenv: ^5.1.0 24 | flutter_heatmap_calendar: ^1.0.4 25 | flutter_highlight: ^0.7.0 26 | flutter_hooks: ^0.20.5 27 | flutter_html: 3.0.0-beta.2 28 | flutter_html_table: ^3.0.0-beta.2 29 | flutter_inappwebview: ^6.1.0 # TODO: upgrade after migrating to FLutter 3.19 30 | flutter_local_notifications: ^17.1.0 31 | flutter_localizations: 32 | sdk: flutter 33 | flutter_scroll_shadow: ^1.2.4 34 | flutter_secure_storage: ^9.0.0 35 | graphql: ^5.1.3 36 | graphql_flutter: ^5.1.2 37 | html: ^0.15.4 38 | infinite_scroll_pagination: ^4.0.0 39 | jiffy: ^6.2.1 # TODO: upgrade after migrating to FLutter 3.19 40 | just_audio: ^0.9.37 41 | path: ^1.8.3 # TODO: upgrade after migrating to FLutter 3.19 42 | path_provider: ^2.1.3 43 | phone_ide: ^1.2.3 44 | photo_view: ^0.15.0 45 | pretty_dio_logger: ^1.2.0-beta-1 # NOTE: Do we still want this? 46 | quick_actions: ^1.0.7 47 | share_plus: ^7.2.2 # TODO: upgrade after migrating to FLutter 3.19 48 | shared_preferences: ^2.2.3 49 | sqflite: ^2.3.3 50 | # TODO: Replace with sqflite methods as below package isn't actively maintained 51 | sqflite_migration_service: ^2.0.0-nullsafety.1 52 | stacked: ^3.4.2 53 | stacked_services: ^1.5.0 54 | ua_client_hints: ^1.2.2 55 | upgrader: ^10.2.0 56 | url_launcher: ^6.2.6 57 | web_socket_channel: ^2.4.0 58 | youtube_player_iframe: ^5.1.2 # 2.3.0 last working with fullscreen 59 | dev_dependencies: 60 | build_runner: ^2.4.9 61 | dependency_validator: ^3.2.3 62 | flutter_driver: 63 | sdk: flutter 64 | flutter_launcher_icons: ^0.13.1 65 | flutter_lints: ^2.0.3 66 | flutter_test: 67 | sdk: flutter 68 | integration_test: 69 | sdk: flutter 70 | mockito: ^5.4.4 71 | sqflite_common_ffi: ^2.3.3 72 | stacked_generator: ^1.6.0 73 | 74 | flutter_icons: 75 | android: "launcher_icon" 76 | ios: true 77 | image_path: "assets/images/app-logo.png" 78 | 79 | flutter: 80 | fonts: 81 | - family: RobotoMono 82 | fonts: 83 | - asset: assets/fonts/RobotoMono-Regular.ttf 84 | - asset: assets/fonts/RobotoMono-Bold.ttf 85 | weight: 700 86 | - family: Lato 87 | fonts: 88 | - asset: assets/fonts/Lato-Regular.ttf 89 | - family: Inter 90 | fonts: 91 | - asset: assets/fonts/Inter-Regular.ttf 92 | - asset: assets/fonts/Inter-Bold.ttf 93 | weight: 700 94 | uses-material-design: true 95 | generate: true 96 | assets: 97 | - .env 98 | - assets/images/ 99 | - assets/sql/ 100 | - assets/test_data/news_post.json 101 | - assets/test_data/news_feed.json 102 | - assets/database/bookmarked-article.db 103 | - assets/learn/ 104 | -------------------------------------------------------------------------------- /mobile-app/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 37 | 38 | 39 | 40 | 41 | 42 | 52 | 54 | 60 | 61 | 62 | 63 | 69 | 71 | 77 | 78 | 79 | 80 | 82 | 83 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /mobile-app/lib/ui/views/learn/widgets/download_button_widget.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:freecodecamp/extensions/i18n_extension.dart'; 3 | import 'package:freecodecamp/models/learn/curriculum_model.dart'; 4 | import 'package:freecodecamp/ui/views/learn/block/block_viewmodel.dart'; 5 | 6 | class DownloadButton extends StatelessWidget { 7 | const DownloadButton({ 8 | Key? key, 9 | required this.model, 10 | required this.block, 11 | }) : super(key: key); 12 | 13 | final BlockViewModel model; 14 | final Block block; 15 | 16 | @override 17 | Widget build(BuildContext context) { 18 | return Column( 19 | children: [ 20 | if (!model.isDownloaded || model.isDownloading) 21 | Container( 22 | width: MediaQuery.of(context).size.width, 23 | margin: const EdgeInsets.symmetric(horizontal: 40), 24 | child: ElevatedButton( 25 | onPressed: !model.isDownloading 26 | ? () async { 27 | await model.startDownload(block); 28 | } 29 | : null, 30 | style: ElevatedButton.styleFrom( 31 | backgroundColor: const Color.fromRGBO(0x2A, 0x2A, 0x40, 1), 32 | ), 33 | child: !model.isDownloading 34 | ? Text(context.t.challenge_download) 35 | : StreamBuilder( 36 | stream: model.learnOfflineService.downloadStream.stream, 37 | builder: ((context, snapshot) { 38 | if (snapshot.connectionState == 39 | ConnectionState.waiting) { 40 | return Text( 41 | context.t.challenge_download_starting, 42 | ); 43 | } 44 | 45 | // if (snapshot.hasError) { 46 | // model.stopDownload(block.dashedName); 47 | 48 | // Timer(const Duration(seconds: 5), () { 49 | // model.setIsDownloading = false; 50 | // }); 51 | 52 | // return const Text('An Error has Occured'); 53 | // } 54 | 55 | if (snapshot.hasData) { 56 | return Text( 57 | '${(snapshot.data as double).toStringAsFixed(2)}%', 58 | ); 59 | } 60 | 61 | return Text( 62 | context.t.challenge_download, 63 | ); 64 | }), 65 | ), 66 | ), 67 | ), 68 | if (model.isDownloaded || model.isDownloading) 69 | Container( 70 | width: MediaQuery.of(context).size.width, 71 | margin: const EdgeInsets.symmetric(horizontal: 40), 72 | child: ElevatedButton( 73 | onPressed: () async { 74 | model.isDownloading 75 | ? model.stopDownload(block, false) 76 | : model.stopDownload(block, true); 77 | }, 78 | style: ElevatedButton.styleFrom( 79 | backgroundColor: const Color.fromRGBO(0x2A, 0x2A, 0x40, 1), 80 | ), 81 | child: model.isDownloading 82 | ? Text( 83 | context.t.challenge_download_cancel, 84 | ) 85 | : Text( 86 | context.t.challenge_download_delete, 87 | ), 88 | ), 89 | ) 90 | ], 91 | ); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /mobile-app/lib/ui/views/settings/delete-account/delete_account_viewmodel.dart: -------------------------------------------------------------------------------- 1 | import 'dart:developer'; 2 | 3 | import 'package:dio/dio.dart'; 4 | import 'package:flutter/material.dart'; 5 | import 'package:freecodecamp/app/app.locator.dart'; 6 | import 'package:freecodecamp/enums/dialog_type.dart'; 7 | import 'package:freecodecamp/extensions/i18n_extension.dart'; 8 | import 'package:freecodecamp/service/authentication/authentication_service.dart'; 9 | import 'package:freecodecamp/service/dio_service.dart'; 10 | import 'package:stacked/stacked.dart'; 11 | import 'package:stacked_services/stacked_services.dart'; 12 | 13 | class DeleteAccountViewModel extends BaseViewModel { 14 | final _authenticationService = locator(); 15 | final _navigator = locator(); 16 | final _snackbar = locator(); 17 | final _dialogService = locator(); 18 | 19 | final Dio _dio = DioService.dio; 20 | bool processing = false; 21 | 22 | void deleteAccount(BuildContext context) async { 23 | processing = true; 24 | DialogResponse? res = await _dialogService.showCustomDialog( 25 | barrierDismissible: true, 26 | variant: DialogType.deleteAccount, 27 | title: context.t.settings_delete_account, 28 | description: context.t.delete_account_are_you_sure, 29 | mainButtonTitle: context.t.settings_delete_account, 30 | ); 31 | 32 | if (res?.confirmed == true) { 33 | showDialog( 34 | context: context, 35 | barrierDismissible: false, 36 | routeSettings: const RouteSettings( 37 | name: '/delete-account-dialog', 38 | ), 39 | builder: (context) { 40 | return PopScope( 41 | canPop: false, 42 | child: SimpleDialog( 43 | title: Text( 44 | context.t.delete_account_deleting, 45 | ), 46 | contentPadding: const EdgeInsets.fromLTRB(0.0, 12.0, 0.0, 24.0), 47 | backgroundColor: const Color(0xFF2A2A40), 48 | shape: RoundedRectangleBorder( 49 | borderRadius: BorderRadius.circular(0), 50 | ), 51 | children: const [ 52 | Center( 53 | child: CircularProgressIndicator(), 54 | ), 55 | ], 56 | ), 57 | ); 58 | }, 59 | ); 60 | notifyListeners(); 61 | 62 | try { 63 | Response res = await _dio.post( 64 | '${AuthenticationService.baseApiURL}/account/delete', 65 | options: Options( 66 | headers: { 67 | 'CSRF-Token': _authenticationService.csrfToken, 68 | 'Cookie': 69 | 'jwt_access_token=${_authenticationService.jwtAccessToken}; _csrf=${_authenticationService.csrf};', 70 | }, 71 | ), 72 | ); 73 | 74 | if (res.statusCode == 200) { 75 | log('Account deleted'); 76 | await _authenticationService.logout(); 77 | _navigator.clearStackAndShow('/'); 78 | _snackbar.showSnackbar( 79 | title: context.t.delete_success, 80 | message: '', 81 | ); 82 | } else { 83 | log('Account deletion failed'); 84 | _navigator.back(); 85 | _snackbar.showSnackbar( 86 | title: context.t.delete_failed, 87 | message: '', 88 | ); 89 | } 90 | } catch (err) { 91 | _navigator.back(); 92 | _snackbar.showSnackbar( 93 | title: context.t.delete_failed, 94 | message: '', 95 | ); 96 | } 97 | } 98 | 99 | processing = false; 100 | notifyListeners(); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /mobile-app/lib/ui/views/news/news-bookmark/news_bookmark_viewmodel.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:freecodecamp/app/app.locator.dart'; 3 | import 'package:freecodecamp/app/app.router.dart'; 4 | import 'package:freecodecamp/models/news/bookmarked_tutorial_model.dart'; 5 | import 'package:freecodecamp/service/news/bookmark_service.dart'; 6 | import 'package:stacked/stacked.dart'; 7 | import 'package:stacked_services/stacked_services.dart'; 8 | 9 | class NewsBookmarkViewModel extends BaseViewModel { 10 | bool _isBookmarked = false; 11 | bool get bookmarked => _isBookmarked; 12 | 13 | bool _gotoTopButtonVisible = false; 14 | bool get gotoTopButtonVisible => _gotoTopButtonVisible; 15 | 16 | bool _userHasBookmarkedTutorials = false; 17 | bool get userHasBookmarkedTutorials => _userHasBookmarkedTutorials; 18 | 19 | late List _tutorials; 20 | List get bookMarkedTutorials => _tutorials; 21 | 22 | int _count = 0; 23 | int get count => _count; 24 | 25 | final _navigationService = locator(); 26 | final _databaseService = locator(); 27 | 28 | ScrollController scrollController = ScrollController(); 29 | 30 | Future initDB() async { 31 | await _databaseService.initialise(); 32 | } 33 | 34 | Future bookmarkAndUnbookmark(dynamic tutorial) async { 35 | if (_isBookmarked) { 36 | _isBookmarked = false; 37 | await _databaseService.removeBookmark(tutorial); 38 | notifyListeners(); 39 | } else { 40 | _isBookmarked = true; 41 | await insertTutorial(tutorial); 42 | notifyListeners(); 43 | } 44 | } 45 | 46 | Future updateListView() async { 47 | _tutorials = await _databaseService.getBookmarks(); 48 | _count = _tutorials.length; 49 | notifyListeners(); 50 | } 51 | 52 | Future refresh() async { 53 | await updateListView(); 54 | await hasBookmarkedTutorials(); 55 | } 56 | 57 | Future goToTop() async { 58 | await scrollController.animateTo( 59 | 0, 60 | duration: const Duration(milliseconds: 300), 61 | curve: Curves.easeInOut, 62 | ); 63 | } 64 | 65 | Future goToTopButtonHandler() async { 66 | scrollController.addListener(() { 67 | if (scrollController.offset >= 100) { 68 | if (!_gotoTopButtonVisible) { 69 | _gotoTopButtonVisible = true; 70 | notifyListeners(); 71 | } 72 | } else { 73 | if (_gotoTopButtonVisible) { 74 | _gotoTopButtonVisible = false; 75 | notifyListeners(); 76 | } 77 | } 78 | }); 79 | } 80 | 81 | Future insertTutorial(dynamic tutorial) async { 82 | bool isInDatabase = await _databaseService.isBookmarked(tutorial); 83 | 84 | if (isInDatabase == false) { 85 | await _databaseService.addBookmark(tutorial); 86 | } 87 | } 88 | 89 | Future isTutorialBookmarked(dynamic tutorial) async { 90 | _isBookmarked = await _databaseService.isBookmarked(tutorial); 91 | notifyListeners(); 92 | } 93 | 94 | Future hasBookmarkedTutorials() async { 95 | var isInDatabase = await _databaseService.getBookmarks(); 96 | 97 | if (isInDatabase.isNotEmpty) { 98 | _userHasBookmarkedTutorials = true; 99 | notifyListeners(); 100 | } else { 101 | _userHasBookmarkedTutorials = false; 102 | notifyListeners(); 103 | } 104 | } 105 | 106 | void routeToBookmarkedTutorial(BookmarkedTutorial tutorial) { 107 | _navigationService.navigateTo( 108 | Routes.newsBookmarkTutorialView, 109 | arguments: NewsBookmarkTutorialViewArguments( 110 | tutorial: tutorial, 111 | ), 112 | ); 113 | } 114 | } 115 | --------------------------------------------------------------------------------