├── .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 | 
2 |
3 | [](http://makeapullrequest.com)
4 | [](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 |
--------------------------------------------------------------------------------